A career entry says the same thing in more than one language #131

Closed
opened 2026-09-09 10:25:20 +00:00 by tiagoagueda · 1 comment
Owner

Observation

also multiple language documents cohabiting should be normal

What exists

At the document level this is already true, and better than it looks. Both CV and
CoverLetter carry a language field — "Which language this variant is written in. Leave
blank to follow your profile."
— and rendering.document_language() resolves it best
answer first: what the document says, then what its owner reads Postulo in, then the
instance default, with British English as a last resort rather than an assumption.

document_direction() then reads the layout direction from the document's language and
not the reader's, with the reason written out:

A person reading Postulo in Arabic may write a CV in English, and the PDF that goes out has
to be laid out for the language it is written in — WeasyPrint hyphenates, justifies and
orders the lines by this and by nothing else.

The image carries fonts-noto-core and a test holds the fonts to the languages offered
(#70), so an Amharic or Georgian document renders glyphs rather than boxes. So a French CV
and an English CV can sit side by side today, each declaring itself correctly.

One level down, they cannot say anything different. The career record holds one text per
field per entry:

Model Text that would have to differ per language
Experience role, organisation, location, summary, highlights
Education institution, qualification, field_of_study, grade, highlights
Project name, role, summary, highlights
Certification name, issuer
SkillGroup, Skill name

A CV declaring fr-fr renders exactly the same English job titles and summaries as the one
declaring en-gb. The only per-variant escape is CVItem.override_highlights, which
replaces the highlights on one variant and nothing else — one field out of the table above.

So today the honest way to keep a CV in two languages is two careers: a second set of
entries, typed again, drifting apart from the first the moment a date changes.

What this asks for

A career entry that can say the same thing in more than one language, so that a second
language is a translation rather than a second record.

Worth being careful about

Which of the fields above should even be translated. An organisation's name usually
should not — Universidade de Lisboa stays that in an English CV — and a qualification
sometimes must not, because the awarding body's name is the credential. A blanket
per-language copy of every field would invite somebody to translate the one thing that has
to stay verbatim. This wants a decision per field, not a mechanism applied uniformly.

Three shapes, and they are not equally good. Per-entry translation rows keyed by language
(clean, and a join everywhere); a JSON map of language to overrides on the entry (no
migration per field, no constraints either); or per-variant overrides on CVItem, extending
what override_highlights already does (smallest change, but the translation belongs to the
CV rather than to the career, so a second French CV retypes it).

Falling back has to be visible. A French CV with an untranslated entry should render the
original rather than a blank — and the person should be able to see which entries fell
back, or they will discover it in the PDF an employer already has.

The export and the importer. Whatever holds the translations travels in the archive and
comes back; core/export.py walks the career models field by field, and the Europass reader
fills them.

Europass is bilingual in practice. The importer reads a file that may itself carry more
than one language, and today collapses it to one. Worth knowing before designing the
storage, rather than after.

Letters are different from CVs here. A letter is prose the person wrote, not a selection
from a record; two languages means two letters, which is already possible and probably
correct. This issue is about the career record behind a CV, a portfolio and a report — the
kinds that assemble rather than the ones that are written.

Every language on a document is a language the fonts must draw. That already holds for
the interface; it now applies to a person's own words in a PDF, which is a different code
path (WeasyPrint, or Chromium) with its own font resolution.

## Observation > also multiple language documents cohabiting should be normal ## What exists **At the document level this is already true, and better than it looks.** Both `CV` and `CoverLetter` carry a `language` field — *"Which language this variant is written in. Leave blank to follow your profile."* — and `rendering.document_language()` resolves it best answer first: what the document says, then what its owner reads Postulo in, then the instance default, with British English as a last resort rather than an assumption. `document_direction()` then reads the layout direction from **the document's** language and not the reader's, with the reason written out: > A person reading Postulo in Arabic may write a CV in English, and the PDF that goes out has > to be laid out for the language it is written in — WeasyPrint hyphenates, justifies and > orders the lines by this and by nothing else. The image carries `fonts-noto-core` and a test holds the fonts to the languages offered (#70), so an Amharic or Georgian document renders glyphs rather than boxes. So a French CV and an English CV can sit side by side today, each declaring itself correctly. **One level down, they cannot say anything different.** The career record holds one text per field per entry: | Model | Text that would have to differ per language | | --- | --- | | `Experience` | `role`, `organisation`, `location`, `summary`, `highlights` | | `Education` | `institution`, `qualification`, `field_of_study`, `grade`, `highlights` | | `Project` | `name`, `role`, `summary`, `highlights` | | `Certification` | `name`, `issuer` | | `SkillGroup`, `Skill` | `name` | A CV declaring `fr-fr` renders exactly the same English job titles and summaries as the one declaring `en-gb`. The only per-variant escape is `CVItem.override_highlights`, which replaces the highlights on one variant and nothing else — one field out of the table above. So today the honest way to keep a CV in two languages is two careers: a second set of entries, typed again, drifting apart from the first the moment a date changes. ## What this asks for A career entry that can say the same thing in more than one language, so that a second language is a translation rather than a second record. ## Worth being careful about **Which of the fields above should even be translated.** An organisation's name usually should not — *Universidade de Lisboa* stays that in an English CV — and a qualification sometimes must not, because the awarding body's name is the credential. A blanket per-language copy of every field would invite somebody to translate the one thing that has to stay verbatim. This wants a decision per field, not a mechanism applied uniformly. **Three shapes, and they are not equally good.** Per-entry translation rows keyed by language (clean, and a join everywhere); a JSON map of language to overrides on the entry (no migration per field, no constraints either); or per-variant overrides on `CVItem`, extending what `override_highlights` already does (smallest change, but the translation belongs to the CV rather than to the career, so a second French CV retypes it). **Falling back has to be visible.** A French CV with an untranslated entry should render the original rather than a blank — and the person should be able to see *which* entries fell back, or they will discover it in the PDF an employer already has. **The export and the importer.** Whatever holds the translations travels in the archive and comes back; `core/export.py` walks the career models field by field, and the Europass reader fills them. **Europass is bilingual in practice.** The importer reads a file that may itself carry more than one language, and today collapses it to one. Worth knowing before designing the storage, rather than after. **Letters are different from CVs here.** A letter is prose the person wrote, not a selection from a record; two languages means two letters, which is already possible and probably correct. This issue is about the career record behind a CV, a portfolio and a report — the kinds that *assemble* rather than the ones that are *written*. **Every language on a document is a language the fonts must draw.** That already holds for the interface; it now applies to a person's own words in a PDF, which is a different code path (WeasyPrint, or Chromium) with its own font resolution.
tiagoagueda added this to the 0.3.0 milestone 2026-09-09 10:25:20 +00:00
Author
Owner

Done in 28551c13.

The shape chosen, and why not the other two

Of the three the issue listed, this is per-entry translation rows keyed by language — but a row per field, not a row per entry with a column per field.

  • A column per field would need a migration every time somebody wants a new field said differently, and a table holding the union of every model's translatable fields, most of them null on most rows.
  • A JSON map on the entry would need neither, and would also accept a field that does not exist, a language that is not a language, and two spellings of the same key. The unique constraint is what it could not have had: one text per field per language per entry, decided by the database rather than by whichever save happened to be last.
  • Per-variant overrides on CVItem were rejected for the reason the issue gave: the translation would belong to the CV rather than to the career, so a second French CV would retype it.

So: a generic link, exactly as CVItem and PostalAddress already use, with the field name checked against translating.TRANSLATABLE.

Which fields — the decision, not a mechanism

You were right that a blanket per-language copy of every field would invite somebody to translate the one thing that has to stay verbatim, and that offering the box is the harm. So the boxes that would have caused it are not there:

Model Translatable Not, and why
Experience role, location, summary, highlights organisation — Universidade de Lisboa stays that on an English CV
Education qualification, field_of_study, location, grade, highlights institution — same reason
Project name, role, summary, highlights —
SkillGroup, Skill, LanguageSkill name —
Link title, description url, kind
Certification nothing the name of a credential is the credential

Certification translating nothing at all is a statement rather than a gap, and the screen says so in words rather than showing an empty form.

What is offered is either the person's own words or something that genuinely differs between languages: a job title, a city (Lisbon, Lisboa), a qualification, a grade on a scale that does not exist elsewhere.

Falling back, made visible

An entry with nothing in the CV's language prints its original — a blank line where a job used to be is worse than a line in the wrong language. The CV's own page then lists which entries did that and in which fields, each linking to its translation screen, above the export button. Same rule #132 settled for themes, one issue earlier, for the same reason.

That is what the one new profile field earns its place with. Without "which language is your career record written in", a CV declaring that same language would report every entry as untranslated, and a warning that always fires is read once and then never again. Blank means "the same as the interface", which is a guess rather than a claim — and the reason it is not simply read from the interface language is that the two are genuinely different questions: somebody reading Postulo in English may have typed their career in Portuguese.

What the templates had to know: nothing

build_sections hands each theme an Entry exposing the two names a template already reads — entry.item and entry.highlight_lines — so every theme renders a translated CV without knowing translations exist. That matters most for the themes Postulo has never seen: a plugin's template written against entry.item.role gets the French job title and could not have been asked to do anything else.

One entry is printed through another and needed handling: a Skill is never on a CV in its own right, only through its group's skill_names. Offering to translate a skill's name and then never printing the translation would have been a worse promise than not offering it.

Cost

A generic link has no join to select_related, so the same batch lookup #130 introduced answers for it: one query for a whole CV's translations, not one per entry. The career overview does the same for its "Also in French, German" line.

Europass

Left where it was, deliberately, and the module says so. The reader collapses each file to one language; reading two files as one career is its own piece of work. What this had to get right first was being able to hold the second file's text when it arrives — which was your point about knowing before designing the storage rather than after — and it can: the rows are keyed by (entry, language, field) and care nothing about where the text came from.

Also

  • pt-br takes pt-pt where no pt-br exists. A Brazilian reader given European Portuguese has read the entry; given English, they have not.
  • A cleared box leaves an empty row and prints the original. Blank is withdrawal, not emptiness — and a form that deletes rows on save loses work to a mis-click on a field nobody opened.
  • A CV whose theme came from a removed plugin, and an entry whose translation was cleared, both degrade the same way: to what is actually there, saying so.
  • Archive format 11 carries the translations and the record language; an archive written before it restores unchanged.
  • tests/test_career_languages.py, 27 tests. Suite 4138 passed, 29 skipped; browser suite 54 passed, including axe-core over the new screen in both themes.

One thing found on the way, fixed separately in 144978f2: #130 took related_name="renders" with the two columns it replaced, and both detail pages ask for exactly that name — so opening a CV or a letter raised an AttributeError. The tests written for #130 checked what the new link stores and what happens when each end is deleted, and never opened the page that reads it back. The reverse is a query rather than a GenericRelation, because a relation would bring a cascade with it and deleting a CV must leave the PDF an employer received where it is.

Done in `28551c13`. ## The shape chosen, and why not the other two Of the three the issue listed, this is **per-entry translation rows keyed by language** — but a row per *field*, not a row per entry with a column per field. - **A column per field** would need a migration every time somebody wants a new field said differently, and a table holding the union of every model's translatable fields, most of them null on most rows. - **A JSON map on the entry** would need neither, and would also accept a field that does not exist, a language that is not a language, and two spellings of the same key. The unique constraint is what it could not have had: one text per field per language per entry, decided by the database rather than by whichever save happened to be last. - **Per-variant overrides on `CVItem`** were rejected for the reason the issue gave: the translation would belong to the CV rather than to the career, so a second French CV would retype it. So: a generic link, exactly as `CVItem` and `PostalAddress` already use, with the field name checked against `translating.TRANSLATABLE`. ## Which fields — the decision, not a mechanism You were right that a blanket per-language copy of every field would invite somebody to translate the one thing that has to stay verbatim, and that **offering the box is the harm**. So the boxes that would have caused it are not there: | Model | Translatable | Not, and why | | --- | --- | --- | | `Experience` | `role`, `location`, `summary`, `highlights` | `organisation` — *Universidade de Lisboa* stays that on an English CV | | `Education` | `qualification`, `field_of_study`, `location`, `grade`, `highlights` | `institution` — same reason | | `Project` | `name`, `role`, `summary`, `highlights` | — | | `SkillGroup`, `Skill`, `LanguageSkill` | `name` | — | | `Link` | `title`, `description` | `url`, `kind` | | `Certification` | **nothing** | the name of a credential *is* the credential | `Certification` translating nothing at all is a statement rather than a gap, and the screen says so in words rather than showing an empty form. What *is* offered is either the person's own words or something that genuinely differs between languages: a job title, a city (Lisbon, Lisboa), a qualification, a grade on a scale that does not exist elsewhere. ## Falling back, made visible An entry with nothing in the CV's language prints its original — a blank line where a job used to be is worse than a line in the wrong language. The CV's own page then lists which entries did that and in which fields, each linking to its translation screen, **above the export button**. Same rule #132 settled for themes, one issue earlier, for the same reason. That is what the one new profile field earns its place with. Without "which language is your career record written in", a CV declaring that same language would report every entry as untranslated, and a warning that always fires is read once and then never again. Blank means "the same as the interface", which is a guess rather than a claim — and the reason it is not simply *read* from the interface language is that the two are genuinely different questions: somebody reading Postulo in English may have typed their career in Portuguese. ## What the templates had to know: nothing `build_sections` hands each theme an `Entry` exposing the two names a template already reads — `entry.item` and `entry.highlight_lines` — so **every theme renders a translated CV without knowing translations exist**. That matters most for the themes Postulo has never seen: a plugin's template written against `entry.item.role` gets the French job title and could not have been asked to do anything else. One entry is printed through another and needed handling: a `Skill` is never on a CV in its own right, only through its group's `skill_names`. Offering to translate a skill's name and then never printing the translation would have been a worse promise than not offering it. ## Cost A generic link has no join to `select_related`, so the same batch lookup #130 introduced answers for it: **one query for a whole CV's translations**, not one per entry. The career overview does the same for its "Also in French, German" line. ## Europass Left where it was, deliberately, and the module says so. The reader collapses each file to one language; reading two files as one career is its own piece of work. What this had to get right *first* was being able to hold the second file's text when it arrives — which was your point about knowing before designing the storage rather than after — and it can: the rows are keyed by (entry, language, field) and care nothing about where the text came from. ## Also - `pt-br` takes `pt-pt` where no `pt-br` exists. A Brazilian reader given European Portuguese has read the entry; given English, they have not. - A cleared box leaves an empty row and prints the original. Blank is withdrawal, not emptiness — and a form that deletes rows on save loses work to a mis-click on a field nobody opened. - A CV whose theme came from a removed plugin, and an entry whose translation was cleared, both degrade the same way: to what is actually there, saying so. - Archive format 11 carries the translations and the record language; an archive written before it restores unchanged. - `tests/test_career_languages.py`, 27 tests. Suite 4138 passed, 29 skipped; browser suite 54 passed, including axe-core over the new screen in both themes. **One thing found on the way**, fixed separately in `144978f2`: #130 took `related_name="renders"` with the two columns it replaced, and both detail pages ask for exactly that name — so opening a CV or a letter raised an `AttributeError`. The tests written for #130 checked what the new link stores and what happens when each end is deleted, and never opened the page that reads it back. The reverse is a query rather than a `GenericRelation`, because a relation would bring a cascade with it and deleting a CV must leave the PDF an employer received where it is.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Reference
Postulo/postulo#131
No description provided.