feat: a list label takes the alignment the file gives it
Word's w:lvlJc / ODF's fo:text-align on the level: set right, the label grows into the margin instead of over the text it labels. That is what the built-in Roman numberings use, and a fixture's numerals collided without it.
This commit is contained in:
1 parent
ee1b0dd180
commit
3154a9b7a2
10 files changed
+113
-23
No files matched your search
@@ -9,5 +9,5 @@ list markers, paragraph boxes and date/time fields.
|
||||
- **`tabStops.ts`** (`TabStops`) — per-paragraph tab stops (Word's `w:tabs`, ODF `style:tab-stops`). Global attr `tabStops` on paragraph/heading, rendered as `data-tab-stops`; canonical string `'<cm><align code>'` per stop, `;`-separated (`'6c;12r;16d'`, codes `l|c|r|d`), parsed only by the exported `parseTabStops`/`formatTabStops`. Positions are **cm from the left text margin** — the origin Word and ODF share, verified by probe (a Word stop at 100mm in a 30mm-indented paragraph stays 100mm in ODF and LibreOffice renders it there), so neither I/O side shifts them by the paragraph indent. **Rendering**: CSS only has the fixed `tab-size` grid, so a rAF measuring plugin (shaped like `pageBreaks.ts`, whose `FORCE_PAGE_RECALC` it both listens to and fires) computes each tab's advance from `coordsAtPos` against `.tiptap`'s content edge and emits `Decoration.inline(tab, tab+1, {style:'tab-size:0;margin-left:Npx'})` — `tab-size:0` collapses the tab's own advance, the inline margin supplies the exact width, so no `display` change and no baseline/line-height side effects. The margin is on the **left** because the gap is the tab's own advance: as `margin-right` a caret placed after the tab rendered before the gap, i.e. at the pre-tab x until the next keystroke. The pen position is read with **side −1** (the end of the content before the tab), which lies outside the tab's own span — measuring the tab itself would fold the margin just applied back into the next pass's reading. `nextStop` takes the first custom stop right of the tab, else leaves the tab undecorated (past the last custom stop the CSS grid already does the right thing, which is also why custom stops suppress the default ones to their left). A **negative `indentFirst` implies a stop at the block's indent** (the hanging-indent stop). Centre/right/decimal take back the segment up to the next tab, measured as the **sum of its client rects** (`rangeWidth`) — reading its span end to end instead made a segment that its own advance had pushed onto the next line measure as wrapped, and the alignment could never recover. The scale comes from the editor's **width**: `offsetHeight` is rounded, and a one-line zone is short enough for that to be half a percent. Passes converge (one more tab settles per pass, `MAX_PASSES`), since a stop can rewrap the line. **UI**: `Ruler.svelte`. A **header/footer zone** carries stops too — the left⇥centre⇥right layout rides on them — but an inactive one is static `generateHTML` output no plugin reaches, so `layOutZoneTabs` measures it straight on the DOM (`HeaderFooterLayer.svelte`, `use:layOutTabs`), wrapping each tab in a span of its own. Advances are layout px, so the zoom transform doesn't invalidate them. A decimal stop there behaves as right. A stop may carry a **leader** (`w:tab w:leader` / `style:leader-text`, one of `. - _ ·`), drawn as a `::before` clipped to the measured gap so the fill stays out of the document's text; on the ODF side odf-kit has no leader option and mints one automatic style per option set, so the leader rides `style:type` behind a `LEAD` sentinel that `applyTabLeaders` unpacks — a bare position+type lookup would give two stops that agree on both the same leader. Past the last custom stop the grid is the document's own **default tab interval** (`storage/tabInterval.ts`, 1.25cm for a new document — LibreOffice's; Word's is 1.27): imported from `w:defaultTabStop` / the paragraph default-style's `style:tab-stop-distance`, applied as the `--tab-interval` CSS var, and **always** written back on export. A file that declares none falls back to its format's own value — measured, LibreOffice puts a bare `A⇥X` at **2cm**, not at its UI default — so leaving ours implied would render differently everywhere else (`ODF_IMPLIED_TAB_CM`, `DOCX_IMPLIED_TAB_CM`).
|
||||
|
||||
A **run of tabs wider than the line** continues on the next one with the grid starting over, as LibreOffice lays it out; Chromium instead hangs the leftover tabs and starts the next line at the margin. `runBreaks` walks the run analytically from the pen *before* it — the one thing the fix can't move, so the answer is the same whether or not the break is already in place — and emits a `<br>` widget where the line runs out. Only runs of two or more are considered: reading a block's geometry forces a reflow, and doing it for every tab in a 48-page document slowed pagination enough to skew the parity harness.
|
||||
- **`listMarker.ts`** (`ListMarker`) — the marker's **family, weight, slant, size and color**, taken from the item's **first text portion** (`markerFormat`): a mark lives inside the item's paragraph and never reaches `::marker`, which only inherits the `li`'s own font, so a bold numbered heading would keep a light number. A **character style** on that portion is resolved through the registry (`charStyleProps`, handed in by the caller: the plugin from its `sheet` option, the exporters from their `exportSheet`); what a **named paragraph style** provides reaches the marker through `styleCss`'s `li:has(> …)` rule instead. First portion, not the whole line, is LibreOffice's own numbering rule (verified by probe: bold-then-plain gets a bold number, plain-then-bold does not; Word instead takes the paragraph mark). **Rendering**: a node decoration puts `--marker-family/-weight/-style/-size/-color` on **every** `listItem` — they inherit, so an item without its own format resets them to `initial` (the guaranteed-invalid value, so the `var(…, inherit)` in `editor.css` falls back; `inherit` would pull the parent item's value in), and `editor.css` has one rule reading them on the item block's `::before` — which is what **draws** the marker: `::marker` cannot be positioned, and Chromium sets it flush against the text, ~4mm right of the level's hanging indent (`LIST_HANGING_CM` 0.635cm, what both exports write). The pseudo is **absolutely positioned** at that indent, because in flow it grew every list line by a pixel. `list-style: none` drops only the marker box, so the `list-item` counter and `<ol start>` keep numbering, and `content: counter(list-item, …)`/`counters(list-item, '.')` per `data-eff-list-style` replaces `list-style-type`; a `bulletChar` rides `--bullet` (an `attr()` inside the pseudo would read the block, not the list). `export/pdf.ts` switches the pseudo off and injects the same marker as a real span, since html2canvas cannot paint counters. The bullet shim keeps the symbol font first and takes `--marker-family` as its fallback. The pass also re-runs on `FORCE_PAGE_RECALC`, so editing a character style repaints the markers. **Export**: both formats carry marker formatting **per level**, so `listMarkerFormat` returns one only when *every* item of the list agrees — ODF mints a **named** `style:family="text"` style **in styles.xml** and points the level definition at it via `text:style-name` (`collectListMarkerFormats`/`applyListMarkerFormats`) — LibreOffice ignores the reference when it resolves to an automatic style in content.xml (probed), DOCX writes the level's `w:rPr` (`markerRunProps` → `ILevelsOptions.style.run`). A **mixed** list gets no level format on purpose: LibreOffice then applies its first-portion rule and matches the editor, Word falls back to the paragraph mark (which only carries the size). Import needs nothing — the runs already carry the formatting. Nested lists that odf-kit restyles (`applyNestedListTypes`) and in-cell lists keep a plain marker. A character style **inside a list item** is handled by an ODF pre-pass (`bakeListCharStyles`) instead: odf-kit's list builder reads marks itself and knows nothing about `charStyle`, and `applyRuns` never sees a list paragraph, so the pre-pass does both of its jobs — bake the resolved props into direct marks (which is what gets the run a `<text:span>` at all) and prefix the `CST` sentinel, so `applyCharacterStyles` re-points that span at the named style like everywhere else. Formatting and name both round-trip. `tests/unit/list-marker.test.ts` covers the four LibreOffice cases, the reset, and both export legs.
|
||||
- **`listMarker.ts`** (`ListMarker`) — the marker's **family, weight, slant, size and color**, taken from the item's **first text portion** (`markerFormat`): a mark lives inside the item's paragraph and never reaches `::marker`, which only inherits the `li`'s own font, so a bold numbered heading would keep a light number. A **character style** on that portion is resolved through the registry (`charStyleProps`, handed in by the caller: the plugin from its `sheet` option, the exporters from their `exportSheet`); what a **named paragraph style** provides reaches the marker through `styleCss`'s `li:has(> …)` rule instead. First portion, not the whole line, is LibreOffice's own numbering rule (verified by probe: bold-then-plain gets a bold number, plain-then-bold does not; Word instead takes the paragraph mark). **Rendering**: a node decoration puts `--marker-family/-weight/-style/-size/-color` on **every** `listItem` — they inherit, so an item without its own format resets them to `initial` (the guaranteed-invalid value, so the `var(…, inherit)` in `editor.css` falls back; `inherit` would pull the parent item's value in), and `editor.css` has one rule reading them on the item block's `::before` — which is what **draws** the marker: `::marker` cannot be positioned, and Chromium sets it flush against the text, ~4mm right of the level's hanging indent (`LIST_HANGING_CM` 0.635cm, what both exports write). The pseudo is **absolutely positioned** at that indent, because in flow it grew every list line by a pixel. `list-style: none` drops only the marker box, so the `list-item` counter and `<ol start>` keep numbering, and `content: counter(list-item, …)`/`counters(list-item, '.')` per `data-eff-list-style` replaces `list-style-type`; a `bulletChar` rides `--bullet` (an `attr()` inside the pseudo would read the block, not the list). `export/pdf.ts` switches the pseudo off and injects the same marker as a real span, since html2canvas cannot paint counters. The bullet shim keeps the symbol font first and takes `--marker-family` as its fallback. The pass also re-runs on `FORCE_PAGE_RECALC`, so editing a character style repaints the markers. **Export**: both formats carry marker formatting **per level**, so `listMarkerFormat` returns one only when *every* item of the list agrees — ODF mints a **named** `style:family="text"` style **in styles.xml** and points the level definition at it via `text:style-name` (`collectListMarkerFormats`/`applyListMarkerFormats`) — LibreOffice ignores the reference when it resolves to an automatic style in content.xml (probed), DOCX writes the level's `w:rPr` (`markerRunProps` → `ILevelsOptions.style.run`). A **mixed** list gets no level format on purpose: LibreOffice then applies its first-portion rule and matches the editor, Word falls back to the paragraph mark (which only carries the size). Import needs nothing — the runs already carry the formatting. Nested lists that odf-kit restyles (`applyNestedListTypes`) and in-cell lists keep a plain marker. A character style **inside a list item** is handled by an ODF pre-pass (`bakeListCharStyles`) instead: odf-kit's list builder reads marks itself and knows nothing about `charStyle`, and `applyRuns` never sees a list paragraph, so the pre-pass does both of its jobs — bake the resolved props into direct marks (which is what gets the run a `<text:span>` at all) and prefix the `CST` sentinel, so `applyCharacterStyles` re-points that span at the named style like everywhere else. Formatting and name both round-trip. `tests/unit/list-marker.test.ts` covers the four LibreOffice cases, the reset, and both export legs. **Label alignment** (`markerAlign` on the list node, Word's `w:lvlJc`, ODF's `fo:text-align` on the level properties): `right` sets the label's *far* end against the hanging indent, so a wide number — the Roman numerals Word's built-in numberings use it for — grows into the margin instead of over the text. `tests/unit/marker-align.test.ts` round-trips it.
|
||||
- **`dateTimeField.ts`** (`DateTimeField`) — inline atom for an inserted date/time field (Word's "Insert Date and Time"). Attrs: `kind` (`'date'|'time'`), `format` (a catalog key), `fixed`, `value` (ISO local datetime captured at insert). A **fixed** field renders its stored `value`; an **auto** field renders the *current* moment on every load (`renderHTML`/`renderText` via `dateTimeFieldText`) — no plugin, Word/LibreOffice likewise only refresh on open. As an inline atom with no children it can't inherit the surrounding run's font from a sibling span, so it **carries that run's marks** on the node: insert adopts the cursor's stored marks, and both importers attach the field run's resolved marks (else it falls back to the editor default font). Command `insertDateTimeField({kind,format,fixed})`. Formats live in `utils/dateTime.ts` as a **token model** (one `Token[]` per format) so the three consumers stay in agreement: `renderFormat` (locale display via `Intl`), `odfNumberStyle` (`<number:date-style>`/`<number:time-style>` body), `docxPicture` (Word field switch). `DateTimePicker.svelte` (a dropdown next to `SpecialCharPicker` in `ToolbarExpanded.svelte`) lists live samples + an "update automatically" checkbox. Not in the header/footer schema (there a `<text:date>`/DATE field stays plain text). **ODF**: `replaceDateTimeFields`/`applyDateTimeFields` (sentinel U+E00A, mirrors images) emit `<text:date text:date-value text:fixed style:data-style-name>`/`<text:time text:time-value …>` + a minted per-format number style (language from the export doc language; `ensureNumberNamespace` adds `xmlns:number`); import matches the referenced number style back to a format via `parseNumberStyleTokens` + `matchFormat` (unknown ⇒ keep the shown text). **DOCX**: auto → `SimpleField` `DATE`/`TIME \@ "picture"` with a cached run; fixed → a plain text run (Word has no fixed-date field); import maps a `DATE`/`TIME` `fldSimple`/complex field's picture back to a format (`dateTimeFieldFromInstr`).
|
||||
@@ -106,6 +106,25 @@ export const ListMarker = Extension.create<{ sheet: () => StyleSheet }>({
|
||||
return { sheet: () => ({ paragraph: {}, character: {}, table: {} }) };
|
||||
},
|
||||
|
||||
// Which end of the hanging indent the label is set against (Word's w:lvlJc, ODF's
|
||||
// fo:text-align on the level properties). `right` keeps a wide number — Roman
|
||||
// numerals — out of the text, which is what the built-in numberings use it for.
|
||||
addGlobalAttributes() {
|
||||
return [
|
||||
{
|
||||
types: ['bulletList', 'orderedList'],
|
||||
attributes: {
|
||||
markerAlign: {
|
||||
default: null,
|
||||
parseHTML: (el: HTMLElement) => (el.getAttribute('data-marker-align') === 'right' ? 'right' : null),
|
||||
renderHTML: (attrs: Record<string, unknown>) =>
|
||||
attrs.markerAlign === 'right' ? { 'data-marker-align': 'right' } : {},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const props = () => charStyleProps(this.options.sheet());
|
||||
return [
|
||||
|
||||
@@ -163,6 +163,8 @@ class Numbering {
|
||||
left: cmToTwip((depth + 1) * LIST_LEFT_STEP_CM + extraIndentCm),
|
||||
hanging: cmToTwip(LIST_HANGING_CM),
|
||||
};
|
||||
// w:lvlJc: which end of the hanging indent the label is set against.
|
||||
const alignment = node.attrs?.markerAlign === 'right' ? AlignmentType.RIGHT : AlignmentType.LEFT;
|
||||
if (node.type === 'orderedList') {
|
||||
const attr = node.attrs?.listStyleType as string | null | undefined;
|
||||
const chained = this.mlRefs.has(reference) && (depth === 0 || !attr);
|
||||
@@ -173,7 +175,7 @@ class Numbering {
|
||||
text: chained
|
||||
? Array.from({ length: depth + 1 }, (_, i) => `%${i + 1}.`).join('')
|
||||
: `%${depth + 1}${def.numSuffix}`,
|
||||
alignment: AlignmentType.LEFT,
|
||||
alignment,
|
||||
start: typeof node.attrs?.start === 'number' ? node.attrs.start : 1,
|
||||
style: { paragraph: { indent }, run: markerRunProps(node) },
|
||||
});
|
||||
@@ -184,7 +186,7 @@ class Numbering {
|
||||
// The Unicode char goes into w:lvlText literally; Word renders it in the
|
||||
// paragraph font (no Wingdings/Symbol rFonts needed).
|
||||
text: bulletCharOf(node, depth),
|
||||
alignment: AlignmentType.LEFT,
|
||||
alignment,
|
||||
style: { paragraph: { indent }, run: markerRunProps(node) },
|
||||
});
|
||||
}
|
||||
|
||||
+26
-17
@@ -1618,16 +1618,20 @@ function applyBulletListChars(odtBytes: Uint8Array, chars: (string[] | null)[]):
|
||||
return rezipOdt(files);
|
||||
}
|
||||
|
||||
// Per-level indent (cm) of each top-level bulletList/orderedList, from the first list at
|
||||
// that level. odf-kit uses label-alignment mode (ignores the paragraph margin), so the
|
||||
// shift goes onto the L# list-style's per-level fo:margin-left + list-tab-stop-position.
|
||||
function collectListIndents(node: TiptapNode, result: number[][]): void {
|
||||
// Per-level indent (cm) and label alignment of each top-level list, from the first list
|
||||
// at that level. odf-kit uses label-alignment mode (which ignores the paragraph margin),
|
||||
// so both go onto the L# list-style's own level definitions.
|
||||
type ListLevelProps = { indent: number; right: boolean };
|
||||
|
||||
function collectListLevelProps(node: TiptapNode, result: ListLevelProps[][]): void {
|
||||
for (const child of node.content ?? []) {
|
||||
if (child.type !== 'bulletList' && child.type !== 'orderedList') continue;
|
||||
const levels: number[] = [];
|
||||
const levels: ListLevelProps[] = [];
|
||||
const visit = (list: TiptapNode, depth: number) => {
|
||||
const ind = list.attrs?.indent;
|
||||
if (levels[depth - 1] === undefined) levels[depth - 1] = typeof ind === 'number' ? ind : 0;
|
||||
if (levels[depth - 1] === undefined) {
|
||||
levels[depth - 1] = { indent: typeof ind === 'number' ? ind : 0, right: list.attrs?.markerAlign === 'right' };
|
||||
}
|
||||
for (const item of list.content ?? []) {
|
||||
for (const block of item.content ?? []) {
|
||||
if (block.type === 'bulletList' || block.type === 'orderedList') visit(block, depth + 1);
|
||||
@@ -1639,8 +1643,9 @@ function collectListIndents(node: TiptapNode, result: number[][]): void {
|
||||
}
|
||||
}
|
||||
|
||||
function applyListIndents(odtBytes: Uint8Array, indents: number[][]): Uint8Array {
|
||||
if (indents.every(levels => levels.every(v => !v))) return odtBytes;
|
||||
function applyListLevelProps(odtBytes: Uint8Array, props: ListLevelProps[][]): Uint8Array {
|
||||
const plain = (l: ListLevelProps) => !l?.indent && !l?.right;
|
||||
if (props.every(levels => levels.every(plain))) return odtBytes;
|
||||
|
||||
const files = unzipSync(odtBytes);
|
||||
const contentBytes = files['content.xml'];
|
||||
@@ -1650,21 +1655,25 @@ function applyListIndents(odtBytes: Uint8Array, indents: number[][]): Uint8Array
|
||||
const bump = (cm: number) => (_m: string, attr: string, v: string) =>
|
||||
`${attr}="${(parseFloat(v) + cm).toFixed(3)}cm"`;
|
||||
// The editor nests the levels, so a level's margin grows by every indent above it too.
|
||||
const shiftLevels = (body: string, levels: number[]) =>
|
||||
const rewriteLevels = (body: string, levels: ListLevelProps[]) =>
|
||||
body.replace(/<text:list-level-style-[a-z]+[\s\S]*?(?=<text:list-level-style-|$)/g, (lvl) => {
|
||||
const n = parseInt(/text:level="(\d+)"/.exec(lvl)?.[1] ?? '', 10);
|
||||
const cm = Number.isFinite(n) ? levels.slice(0, n).reduce((a, b) => a + (b || 0), 0) : 0;
|
||||
return cm
|
||||
if (!Number.isFinite(n)) return lvl;
|
||||
const cm = levels.slice(0, n).reduce((a, b) => a + (b?.indent || 0), 0);
|
||||
const shifted = cm
|
||||
? lvl.replace(/(fo:margin-left)="([\d.]+)cm"/g, bump(cm))
|
||||
.replace(/(text:list-tab-stop-position)="([\d.]+)cm"/g, bump(cm))
|
||||
: lvl;
|
||||
return levels[n - 1]?.right
|
||||
? shifted.replace('<style:list-level-properties ', '<style:list-level-properties fo:text-align="end" ')
|
||||
: shifted;
|
||||
});
|
||||
|
||||
indents.forEach((levels, i) => {
|
||||
if (levels.every(v => !v)) return;
|
||||
props.forEach((levels, i) => {
|
||||
if (levels.every(plain)) return;
|
||||
const re = new RegExp(`(<text:list-style style:name="L${i + 1}">)([\\s\\S]*?)(</text:list-style>)`);
|
||||
content = content.replace(re, (_m, open: string, body: string, close: string) =>
|
||||
open + shiftLevels(body, levels) + close);
|
||||
open + rewriteLevels(body, levels) + close);
|
||||
});
|
||||
|
||||
files['content.xml'] = strToU8(content);
|
||||
@@ -3322,9 +3331,9 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
|
||||
const styledLists = applyListItemStyles(numberedOdt, listStyles);
|
||||
|
||||
// Whole-list indent → added to each L# list-style's level margins.
|
||||
const listIndents: number[][] = [];
|
||||
collectListIndents(raw, listIndents);
|
||||
let indentedLists = applyListIndents(styledLists, listIndents);
|
||||
const listLevels: ListLevelProps[][] = [];
|
||||
collectListLevelProps(raw, listLevels);
|
||||
let indentedLists = applyListLevelProps(styledLists, listLevels);
|
||||
|
||||
// Marker formatting → a minted character style on each L# level definition.
|
||||
const markerFormats: (MarkerFormat | null)[][] = [];
|
||||
|
||||
@@ -478,6 +478,7 @@ function makeListNode(ctx: Ctx, numId: number, ilvl: number): Node {
|
||||
const extra = round2(Math.max(-LIST_LEFT_STEP_CM, step - LIST_LEFT_STEP_CM));
|
||||
if (Math.abs(extra) > LIST_INDENT_EPS_CM) attrs.indent = extra;
|
||||
}
|
||||
if (def.rightAligned) attrs.markerAlign = 'right';
|
||||
const node: Node = { type: bullet ? 'bulletList' : 'orderedList', content: [] };
|
||||
if (Object.keys(attrs).length) node.attrs = attrs;
|
||||
return node;
|
||||
|
||||
@@ -39,7 +39,7 @@ export type RunProps = {
|
||||
|
||||
// A numbering level definition (numbering.xml w:lvl). bulletFont is the level's
|
||||
// w:rPr/w:rFonts (Wingdings/Symbol give the bullet glyph its meaning).
|
||||
export type LevelDef = { numFmt?: string; lvlText?: string; leftTwip?: number; start?: number; bulletFont?: string };
|
||||
export type LevelDef = { numFmt?: string; lvlText?: string; leftTwip?: number; start?: number; bulletFont?: string; rightAligned?: boolean };
|
||||
|
||||
// Paragraph spacing from a w:pPr/w:spacing (only the attributes actually present, so
|
||||
// omitted ones inherit up the style chain). before/after in twips, line per w:line.
|
||||
@@ -312,6 +312,8 @@ export class DocxStyles {
|
||||
const start = firstChild(lvl, 'start'); if (start) { const n = parseInt(wVal(start) ?? '', 10); if (Number.isFinite(n)) def.start = n; }
|
||||
const ind = firstChild(lvl, 'pPr') && firstChild(firstChild(lvl, 'pPr')!, 'ind');
|
||||
if (ind) { const l = parseInt(ind.getAttributeNS(W, 'left') ?? ind.getAttributeNS(W, 'start') ?? '', 10); if (Number.isFinite(l)) def.leftTwip = l; }
|
||||
const jc = firstChild(lvl, 'lvlJc');
|
||||
if (jc && (wVal(jc) === 'right' || wVal(jc) === 'end')) def.rightAligned = true;
|
||||
const rf = firstChild(lvl, 'rPr') && firstChild(firstChild(lvl, 'rPr')!, 'rFonts');
|
||||
if (rf) { const f = rf.getAttributeNS(W, 'ascii') ?? rf.getAttributeNS(W, 'hAnsi'); if (f) def.bulletFont = f; }
|
||||
levels.set(ilvl, def);
|
||||
|
||||
@@ -1552,6 +1552,7 @@ function convertList(el: Element, ctx: Ctx, inheritedStyleName: string | null, d
|
||||
const attrs: Record<string, unknown> = {};
|
||||
if (bulletChar) attrs.bulletChar = bulletChar;
|
||||
if (indent != null) attrs.indent = indent;
|
||||
if (listLevelRightAligned(levelDef)) attrs.markerAlign = 'right';
|
||||
const node: Node = { type: 'bulletList', content: items };
|
||||
if (Object.keys(attrs).length) node.attrs = attrs;
|
||||
return node;
|
||||
@@ -1572,11 +1573,23 @@ function convertList(el: Element, ctx: Ctx, inheritedStyleName: string | null, d
|
||||
if (start != null) attrs.start = start;
|
||||
if (listStyleType) attrs.listStyleType = listStyleType;
|
||||
if (indent != null) attrs.indent = indent;
|
||||
if (listLevelRightAligned(levelDef)) attrs.markerAlign = 'right';
|
||||
const node: Node = { type: 'orderedList', content: items };
|
||||
if (Object.keys(attrs).length) node.attrs = attrs;
|
||||
return node;
|
||||
}
|
||||
|
||||
// fo:text-align="end" on the level properties: the label is set against the far end of
|
||||
// its hanging indent (Word's w:lvlJc="right"), so a wide number grows into the margin.
|
||||
function listLevelRightAligned(levelDef: Element | null): boolean {
|
||||
for (const props of Array.from(levelDef?.children ?? [])) {
|
||||
if (props.namespaceURI !== NS.style || props.localName !== 'list-level-properties') continue;
|
||||
const align = props.getAttributeNS(NS.fo, 'text-align');
|
||||
return align === 'end' || align === 'right';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Level's fo:margin-left (cm) from its <style:list-level-label-alignment> (the
|
||||
// label-alignment mode odf-kit/LibreOffice use). null when absent.
|
||||
function listLevelMarginLeftCm(levelDef: Element | null): number | null {
|
||||
|
||||
@@ -510,6 +510,13 @@
|
||||
color: var(--marker-color, inherit);
|
||||
}
|
||||
|
||||
/* A label set against the far end of the hanging indent grows into the margin instead
|
||||
of into the text — what keeps Word's Roman numerals off the words they label. */
|
||||
.paper .tiptap :is(ol, ul)[data-marker-align="right"] > li > :is(p, h1, h2, h3, h4, h5)::before {
|
||||
left: auto;
|
||||
right: calc(100% + 0.635cm);
|
||||
}
|
||||
|
||||
/* Ordered numbering, keyed by data-eff-list-style — the effective numbering
|
||||
orderedList.ts's plugin resolves per <ol> from its attr + ancestor chain. The
|
||||
undecorated <ol> is the plain decimal fallback and must come first. */
|
||||
|
||||
@@ -116,10 +116,12 @@ LibreOffice's 48, with 62 line-level differences, most of them the justification
|
||||
Exporting writes the placeholder back out — the original is gone from the moment it
|
||||
is imported.
|
||||
- **A list level's own hanging indent is not read**, only the 0.635cm both exports
|
||||
write (`LIST_HANGING_CM`). The marker is drawn there and overflows if it is wider,
|
||||
write (`LIST_HANGING_CM`). A **left**-set label wider than that overflows into the text,
|
||||
where Word moves the text to the next list tab — reading `w:lvl/w:pPr/w:ind w:hanging`
|
||||
would settle it. Deliberate for now: in flow, a wide marker pushed the text and cost
|
||||
the thesis fixture a page. (The level's *left* indent is read, at every depth.)
|
||||
the thesis fixture a page. (The level's *left* indent is read at every depth, and a
|
||||
**right**-set label — `markerAlign`, which is what the built-in Roman numberings use —
|
||||
grows into the margin, so the case that actually collided is gone.)
|
||||
What a probe of the ODF side turned up, before anything is built on it: LibreOffice
|
||||
honours a nested level's `fo:text-indent` linearly (−0.300 → label at 2.243cm, −1.000
|
||||
→ 1.543, −1.200 → 1.344, against `fo:margin-left="2.540cm"`) — **except** at −1.270,
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// Which end of the hanging indent a list label is set against: Word's w:lvlJc,
|
||||
// ODF's fo:text-align on the level properties. `right` is what keeps a Roman
|
||||
// numeral out of the text it labels.
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildOdt } from '../../src/lib/export/odt';
|
||||
import { buildDocx } from '../../src/lib/export/docx';
|
||||
import { importOdt } from '../../src/lib/import/odt';
|
||||
import { importDocx } from '../../src/lib/import/docx';
|
||||
|
||||
type N = any;
|
||||
|
||||
const item = (text: string): N => ({ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text }] }] });
|
||||
const doc = (markerAlign?: string): N => ({
|
||||
type: 'doc',
|
||||
content: [{
|
||||
type: 'orderedList',
|
||||
attrs: { listStyleType: 'upper-roman', ...(markerAlign ? { markerAlign } : {}) },
|
||||
content: [item('one'), item('two')],
|
||||
}],
|
||||
});
|
||||
|
||||
const margins = { top: 2, bottom: 2, left: 2, right: 2 };
|
||||
const list = (d: N) => d.content.find((n: N) => n.type === 'orderedList');
|
||||
|
||||
describe('a list label’s alignment', () => {
|
||||
it('round-trips through ODF', async () => {
|
||||
expect(list((await importOdt(await buildOdt(doc('right'), margins, 'portrait'))).content).attrs?.markerAlign).toBe('right');
|
||||
expect(list((await importOdt(await buildOdt(doc(), margins, 'portrait'))).content).attrs?.markerAlign).toBeUndefined();
|
||||
});
|
||||
|
||||
it('round-trips through DOCX', async () => {
|
||||
expect(list(importDocx(await buildDocx(doc('right'), margins, 'portrait')).content).attrs?.markerAlign).toBe('right');
|
||||
expect(list(importDocx(await buildDocx(doc(), margins, 'portrait')).content).attrs?.markerAlign).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user