From 7b43ee71ad94f1e2224b0a5b45330633c3acc30f Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Sat, 5 Sep 2026 09:27:53 +0200 Subject: [PATCH] fix: sections, zones, styles and page numbering survive the round trip in both formats Found with the showcase documents and by fuzzing the export options through the round trip and the schemas. ODT: a style's line spacing as a percentage, section masters laid out from their own zones, variants in schema order, the watermark on section pages, NCName-encoded style names. DOCX: a section begins a page, odd/even and w:fmt document-wide, styles with line spacing, shading and borders, w:numPr and w:pBdr in order. Both importers: the next style, page setup and text width per section. Editor: chapter counters via counter-set, a heading in columns stays with its text. --- src/lib/export/CLAUDE.md | 16 +- src/lib/export/docx.ts | 109 ++++++++--- src/lib/export/odt.ts | 217 ++++++++++++++-------- src/lib/import/CLAUDE.md | 3 +- src/lib/import/docx.ts | 12 +- src/lib/import/docxStyles.ts | 8 +- src/lib/import/odt.ts | 142 +++++++++----- src/lib/import/styleResolver.ts | 19 +- src/lib/styles/CLAUDE.md | 2 +- src/lib/styles/outlineNumbering.ts | 4 +- src/styles/editor.css | 5 + tests/roundtrip.test.ts | 23 ++- tests/unit/outline-numbering.test.ts | 2 + tests/unit/section-page-numbering.test.ts | 6 +- 14 files changed, 395 insertions(+), 173 deletions(-) diff --git a/src/lib/export/CLAUDE.md b/src/lib/export/CLAUDE.md index 1c3f5e4..006ca85 100644 --- a/src/lib/export/CLAUDE.md +++ b/src/lib/export/CLAUDE.md @@ -63,12 +63,26 @@ fallback download shows a one-time hint (`edentext-download-hint`) that the brow - **`collapseRunWhitespace`** — strips the bare `\n` separators odf-kit inserts between runs inside a paragraph (they'd otherwise collapse to spurious spaces mid-word). - **`rewriteStylesXml`** — `style:page-usage="mirrored"` on the page layout when the margins mirror (the left/right pair is already the inner/outer one), default font Liberation Serif → Times New Roman, heading sizes/margins → the editor's values, Standard's `fo:margin-bottom` → 0 (odf-kit emits 0.212cm; every paragraph and list item inherits it, and the editor has no paragraph spacing — see below). - **`normalizeColor`** — coerces colors to `#RRGGBB` (ODF requirement; rejects/normalizes `rgb()` and short hex). -- **Schema conformance** (guarded by `tests/schema-validation.test.ts`; LibreOffice forgives all of this, Word's strict reader does not): `applyOdfVersion` stamps every ODF part **1.3** — the version LibreOffice writes, and the first with `style:header-first` — over odf-kit's 1.2. `draw:image` carries the `xlink:type/show/actuate` trio (`xlink:type` is mandatory beside `xlink:href`); `text:time-value` is an xsd dateTime, never a `PT…S` duration (the ODT importer still reads the legacy duration); `index-entry-link-start/-end` only in TOC entry templates; `text:dont-balance-text-columns` on `style:section-properties`. DOCX: `orderDocxSettings` (last pass) re-sorts `w:settings` children into the fixed CT_Settings sequence the prepend-passes scramble; `w14:paraId` in comments.xml requires `mc:Ignorable="w14"` on the root. `tests/package-lint.test.ts` guards the invariants the schemas cannot express (unique style ids, no dangling style/num/rel references, balanced ranges, manifest completeness). +- **Schema conformance** (guarded by `tests/schema-validation.test.ts`; LibreOffice forgives all of this, Word's strict reader does not): `applyOdfVersion` stamps every ODF part **1.3** — the version LibreOffice writes, and the first with `style:header-first` — over odf-kit's 1.2. `draw:image` carries the `xlink:type/show/actuate` trio (`xlink:type` is mandatory beside `xlink:href`); `text:time-value` is an xsd dateTime, never a `PT…S` duration (the ODT importer still reads the legacy duration); `index-entry-link-start/-end` only in TOC entry templates; `text:dont-balance-text-columns` on `style:section-properties`. DOCX: `orderDocxSettings` (last pass) re-sorts `w:settings` children into the fixed CT_Settings sequence the prepend-passes scramble; `w14:paraId` in comments.xml requires `mc:Ignorable="w14"` on the root. `w:numPr` sits after the keep flags in a heading style's `w:pPr`, and `w:pBdr` sides are re-sorted post-pack (`orderParagraphBorders`: the library writes top, bottom, left, right). `tests/package-lint.test.ts` guards the invariants the schemas cannot express (unique style ids, no dangling style/num/rel references, balanced ranges, manifest completeness). - **A section past the first spells its zones out, blank ones included.** A `w:sectPr` naming no `w:headerReference` is Word's "Link to Previous" and repeats the section above it — measured: a chapter's running head landed on the pages a blank section was meant for. Both zones write an empty part rather than none (`spellOut`, `mkHeaders`). +- **A section begins a page in DOCX** (`w:type` nextPage, as naming a master page does in ODF); + only a columns group inside a section flows on continuously — the importer reads a + continuous break as a columns boundary, so a real section on one lost its break. +- **Odd/even is document-wide in DOCX** (`w:evenAndOddHeaders`): a section not asking for it + gets its running zones copied into its even parts, or Word links them to the section above. + `w:fmt` rides every `w:sectPr` as well: Word has no document default behind it. +- **An ODF section master's layout is built from its own zones** (`layoutFor`: page margin = + edge→zone distance on a side that has a zone, the body margin on one that has none — a + shared layout put a footer-less chapter's body at the footer distance). A variant zone + travels with its running one, in the schema's order (running, left, first); the watermark + is injected after the section masters exist, so their pages show it too. +- **`style:name` is an NCName**: every other character travels as LibreOffice's `_hex_` + (`odfStyleName`, "&" → `_26_`), the display name carries the real one; a sentinel's + payload is serialized text, so its XML escapes come off first. - **A box spanning the text column keeps where it sits across it**, banded or behind the text alike; only a side wrap has its side dictated by the wrap itself. - **An index row's tab stop is written even with no page number running to it** — it is diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index c705d03..2a103b9 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -362,7 +362,7 @@ class Numbering { levels.push({ level: l, format: ORDERED_FORMAT[def.format] ?? LevelFormat.DECIMAL, - text: `${def.prefix}${chain.join('.')}${def.suffix || ' '}`, + text: `${def.prefix}${chain.join('.')}${def.suffix}`, start: def.start ?? 1, alignment: AlignmentType.LEFT, suffix: def.tabCm == null ? LevelSuffix.NOTHING : LevelSuffix.TAB, @@ -1517,11 +1517,13 @@ function applyOutlineNumberingDocx(bytes: Uint8Array, configIndex: number): Uint numId = String(Math.max(0, ...ids) + 1); numbering = numbering.replace('', ``); } + // w:numPr has its place in w:pPr's fixed order: after the keep/break flags, before + // the borders, shading and tabs. for (let n = 1; n <= 9; n++) { const numPr = ``; stylesXml = stylesXml.replace( - new RegExp(`(]*w:styleId="Heading${n}"[\\s\\S]*?)()`), - `$1$2${numPr}`); + new RegExp(`(]*w:styleId="Heading${n}"[\\s\\S]*?(?:]*/>)*)`), + `$1${numPr}`); } files['word/numbering.xml'] = strToU8(numbering); files['word/styles.xml'] = strToU8(stylesXml); @@ -1794,6 +1796,32 @@ function orderDocxSettings(bytes: Uint8Array): Uint8Array { return zipSync(out); } +// Post-pack pass (runs last): the library writes a paragraph's w:pBdr sides as top, +// bottom, left, right; the schema's sequence is top, left, bottom, right, between, bar. +const PBDR_ORDER = ['w:top', 'w:left', 'w:bottom', 'w:right', 'w:between', 'w:bar']; +function orderParagraphBorders(bytes: Uint8Array): Uint8Array { + const files = unzipSync(bytes); + let changed = false; + for (const [path, data] of Object.entries(files)) { + if (!/^word\/.*\.xml$/.test(path)) continue; + const xml = strFromU8(data); + if (!xml.includes('')) continue; + const sorted = xml.replace(/([\s\S]*?)<\/w:pBdr>/g, (_m, inner: string) => { + const sides = inner.match(/]*\/>/g) ?? []; + if (sides.join('') !== inner) return _m; + const rank = (s: string) => PBDR_ORDER.indexOf(/^<(w:\w+)/.exec(s)![1]); + return `${[...sides].sort((a, b) => rank(a) - rank(b)).join('')}`; + }); + if (sorted === xml) continue; + files[path] = strToU8(sorted); + changed = true; + } + if (!changed) return bytes; + const out: Record = {}; + for (const [path, data] of Object.entries(files)) out[path] = [data, { level: 6 }]; + return zipSync(out); +} + // Post-pack pass: no space above the block that opens a page — Word keeps it in the // compatibility set, where LibreOffice reads it into AddParaTableSpacingAtStart. function applySpacingAtStartDocx(bytes: Uint8Array): Uint8Array { @@ -2114,10 +2142,11 @@ function paraShadingOf(attrs: TiptapNode['attrs']) { } function paraBordersOf(attrs: TiptapNode['attrs']) { - const out: { top?: IBorderOptions; right?: IBorderOptions; bottom?: IBorderOptions; left?: IBorderOptions } = {}; - for (const [attr, side] of [['borderTop', 'top'], ['borderRight', 'right'], ['borderBottom', 'bottom'], ['borderLeft', 'left']] as const) { + const out: { top?: IBorderOptions; left?: IBorderOptions; bottom?: IBorderOptions; right?: IBorderOptions } = {}; + for (const [attr, side] of [['borderTop', 'top'], ['borderLeft', 'left'], ['borderBottom', 'bottom'], ['borderRight', 'right']] as const) { const b = parseBorderAttr(attrs?.[attr] as string | null); - if (b && b !== 'none') out[side] = { style: BorderStyle.SINGLE, size: Math.max(2, Math.round(b.widthPt * 8)), color: hexColor(b.color) ?? '000000', space: 1 }; + // w:space is the gap to the rule in points; the editor draws none unless one is set. + if (b && b !== 'none') out[side] = { style: BorderStyle.SINGLE, size: Math.max(2, Math.round(b.widthPt * 8)), color: hexColor(b.color) ?? '000000', space: Number(attrs?.borderPadding) || 0 }; } return Object.keys(out).length ? out : undefined; } @@ -2484,7 +2513,8 @@ function startsSection(node: TiptapNode): boolean { return node.type === 'columns' && node.content?.[0]?.attrs?.sectionBreak === true; } -function bodyGroups(content: TiptapNode[], num: Numbering, contentWidthCm: number): BodyGroup[] { +// `widthCm(section)`: the text width the section's blocks are sized against. +function bodyGroups(content: TiptapNode[], num: Numbering, widthCm: (section: number) => number): BodyGroup[] { const groups: BodyGroup[] = []; let section = 0; let plain: TiptapNode[] = []; @@ -2493,7 +2523,7 @@ function bodyGroups(content: TiptapNode[], num: Numbering, contentWidthCm: numbe let cols: { count: number; gapCm: number; blocks: TiptapNode[] } | null = null; const flushPlain = () => { if (!plain.length) return; - groups.push({ section, columns: null, children: blocksToDocx(plain, num, contentWidthCm) }); + groups.push({ section, columns: null, children: blocksToDocx(plain, num, widthCm(section)) }); plain = []; }; const flushCols = () => { @@ -2501,7 +2531,7 @@ function bodyGroups(content: TiptapNode[], num: Numbering, contentWidthCm: numbe groups.push({ section, columns: { count: cols.count, gapCm: cols.gapCm }, - children: blocksToDocx(mergeJoinedParagraphsJson(cols.blocks), num, contentWidthCm), + children: blocksToDocx(mergeJoinedParagraphsJson(cols.blocks), num, widthCm(section)), }); cols = null; }; @@ -2600,11 +2630,19 @@ function paragraphStyleOf(style: Style): IParagraphStyleOptions { const spacing: Record = {}; if (p.spaceBefore != null) spacing.before = ptToTwip(p.spaceBefore); if (p.spaceAfter != null) spacing.after = ptToTwip(p.spaceAfter); + // A proportional spacing is a factor of the single line (240 twips). + const factor = Number(p.lineHeight); + if (Number.isFinite(factor) && factor > 0 && factor !== 1) spacing.line = Math.round(factor * 240); const paragraph: Record = {}; - if (Object.keys(spacing).length) paragraph.spacing = { ...spacing, line: 240, lineRule: LineRuleType.AUTO }; + if (Object.keys(spacing).length) paragraph.spacing = { ...spacing, ...(spacing.line ? { lineRule: LineRuleType.AUTO } : {}) }; if (p.textAlign) paragraph.alignment = alignOf({ textAlign: p.textAlign }); if (p.indent != null) paragraph.indent = { left: cmToTwip(p.indent) }; if (style.outlineLevel) paragraph.keepNext = true; + // The style's own colored field and rule lines. + const shading = paraShadingOf(p); + if (shading) paragraph.shading = shading; + const border = paraBordersOf(p); + if (border) paragraph.border = border; return { id: docxStyleId(style.name), name: style.name, @@ -2784,7 +2822,14 @@ export async function buildDocx( }; } const body = (docJson.content ?? []).filter((n) => n.type !== 'noteSection'); - const groups = bodyGroups(body, num, contentWidthCm); + // A section with a paper or margins of its own sizes its tables and indexes to them. + const sectionWidthCm = (i: number): number => { + const sets = hf?.sections ?? []; + const s: Partial = sets[Math.min(i, sets.length - 1)] ?? {}; + const m = s.margins ?? margins; + return pageDimsCm(s.format ?? pageFormat, s.orientation ?? orientation).w - m.left - m.right; + }; + const groups = bodyGroups(body, num, sectionWidthCm); // The index fields carry the editor's rows as their cached result; still ask the // reader to update fields on open, so a capable one repaginates + hyperlinks them. @@ -2831,15 +2876,15 @@ export async function buildDocx( header: cmToTwip(hDist), footer: cmToTwip(fDist), }, // w:pgNumType. Word restarts numbering at every section carrying a start, so a - // later section gets one only where it really restarts; the rest continue. - // w:fmt is per section as well, so a roman front matter keeps its own. + // later section gets one only where it really restarts. w:fmt has no document + // default behind it, so every section names the document's unless it has its own. ...(i === 0 ? (pageNumbering.format !== '1' || pageNumbering.start !== 1 ? { pageNumbers: { formatType: DOCX_PAGE_NUM_FORMAT[pageNumbering.format], ...(pageNumbering.start !== 1 ? { start: pageNumbering.start } : {}) } } : {}) - : (s.pageNumberStart != null || s.pageNumberFormat + : (s.pageNumberStart != null || (s.pageNumberFormat ?? pageNumbering.format) !== '1' ? { pageNumbers: { - ...(s.pageNumberFormat ? { formatType: DOCX_PAGE_NUM_FORMAT[s.pageNumberFormat] } : {}), + formatType: DOCX_PAGE_NUM_FORMAT[s.pageNumberFormat ?? pageNumbering.format], ...(s.pageNumberStart != null ? { start: s.pageNumberStart } : {}), } } : {})), @@ -2849,7 +2894,10 @@ export async function buildDocx( // Previous"). A first-page variant rides `first:` and is activated by titlePage below. const mkHeaders = (i: number) => { const s = setAt(i); - const d = para(s.header), f = s.differentFirstPage ? para(s.headerFirst) : null, e = s.differentOddEven ? para(s.headerEven) : null; + // Odd/even is the document's setting: a section not asking for it repeats its + // running zone on even pages, so that is what its even part holds. + const d = para(s.header), f = s.differentFirstPage ? para(s.headerFirst) : null, + e = s.differentOddEven ? para(s.headerEven) : differentOddEven ? d : null; // A watermark (and the fold marks) lives in a header part, so every page variant // needs one — empty where the zone has no text — for the post-passes to inject // into; a variant without its own part would blank the decor on those pages. @@ -2864,12 +2912,13 @@ export async function buildDocx( if (f) h.first = new Header({ children: [paragraphToDocx(f)] }); else if (s.differentFirstPage && spellOut) h.first = new Header({ children: [new Paragraph({})] }); if (e) h.even = new Header({ children: [paragraphToDocx(e)] }); - else if (s.differentOddEven && spellOut) h.even = new Header({ children: [new Paragraph({})] }); + else if (differentOddEven && spellOut) h.even = new Header({ children: [new Paragraph({})] }); return Object.keys(h).length ? h : undefined; }; const mkFooters = (i: number) => { const s = setAt(i); - const d = para(s.footer), f = s.differentFirstPage ? para(s.footerFirst) : null, e = s.differentOddEven ? para(s.footerEven) : null; + const d = para(s.footer), f = s.differentFirstPage ? para(s.footerFirst) : null, + e = s.differentOddEven ? para(s.footerEven) : differentOddEven ? d : null; if (!d && !f && !e && i === 0) return undefined; const fo: { default?: Footer; first?: Footer; even?: Footer } = {}; // Spelled out past the first section, for the reason the headers are. @@ -2878,13 +2927,14 @@ export async function buildDocx( if (f) fo.first = new Footer({ children: [paragraphToDocx(f)] }); else if (s.differentFirstPage && i > 0) fo.first = new Footer({ children: [new Paragraph({})] }); if (e) fo.even = new Footer({ children: [paragraphToDocx(e)] }); - else if (s.differentOddEven && i > 0) fo.even = new Footer({ children: [new Paragraph({})] }); + else if (differentOddEven && i > 0) fo.even = new Footer({ children: [new Paragraph({})] }); return fo; }; const doc = new Document({ // Word's File ▸ Info; an empty field is left out so it does not overwrite Word's own. - creator: props.author.trim() || GENERATOR, + // The creator is the one the library fills in ("Un-named"), so it is always given. + creator: props.author.trim(), ...(props.title.trim() ? { title: props.title.trim() } : {}), ...(props.subject.trim() ? { subject: props.subject.trim() } : {}), ...(props.keywords.trim() ? { keywords: props.keywords.trim() } : {}), @@ -2932,11 +2982,14 @@ export async function buildDocx( ...(setAt(g.section).differentFirstPage && g.section < hfSets.length && groups.findIndex((x) => x.section === g.section) === i ? { titlePage: true } : {}), // A section that must open on a right or left page says so here; Word inserts - // the blank page for it, as LibreOffice does for style:page-usage. - ...(i > 0 && groups.findIndex((x) => x.section === g.section) === i && setAt(g.section).startsOn - ? { type: setAt(g.section).startsOn === 'odd' ? SectionType.ODD_PAGE : SectionType.EVEN_PAGE } - : i > 0 && !(setAt(g.section).pageNumberStart != null && groups.findIndex((x) => x.section === g.section) === i) - ? { type: SectionType.CONTINUOUS } : {}), + // the blank page for it, as LibreOffice does for style:page-usage. Any other + // section begins a page (the default), as naming a master page does in ODF; + // only a columns group inside a section flows on. + ...(i > 0 && groups.findIndex((x) => x.section === g.section) === i + ? (setAt(g.section).startsOn + ? { type: setAt(g.section).startsOn === 'odd' ? SectionType.ODD_PAGE : SectionType.EVEN_PAGE } + : {}) + : i > 0 ? { type: SectionType.CONTINUOUS } : {}), ...(g.columns ? { column: { count: g.columns.count, space: cmToTwip(g.columns.gapCm), equalWidth: true } } : {}), @@ -2965,8 +3018,8 @@ export async function buildDocx( const dims = pageDimsCm(pageFormat, orientation); const foldMarked = applyFoldMarksDocx(bidi, foldMarks, dims.w * 10); const marked = spacingAtPageStart ? foldMarked : applySpacingAtStartDocx(foldMarked); - if (isEmptyPageDecor(decor)) return orderDocxSettings(marked); + if (isEmptyPageDecor(decor)) return orderParagraphBorders(orderDocxSettings(marked)); const pt = (cm: number) => (cm / 2.54) * 72; - return orderDocxSettings(applyPageDecorDocx(marked, decor, - pt(dims.w - margins.left - margins.right), pt(dims.h - margins.top - margins.bottom))); + return orderParagraphBorders(orderDocxSettings(applyPageDecorDocx(marked, decor, + pt(dims.w - margins.left - margins.right), pt(dims.h - margins.top - margins.bottom)))); } diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index 251b36d..a564977 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -12,7 +12,7 @@ import { DEFAULT_PAGE_NUMBERING, type PageNumbering } from '../storage/pageNumbe import { EMPTY_PAGE_DECOR, type PageDecor, type Watermark } from '../storage/pageDecor'; import { FOLD_MARK_MM, PUNCH_MARK_MM, MARK_START_MM, FOLD_MARK_LEN_MM, PUNCH_MARK_LEN_MM, FOLD_MARK_NAME } from '../storage/foldMarks'; import { DEFAULT_LINE_NUMBERING, type LineNumbering } from '../storage/lineNumbering'; -import { builtinStyleSheet, DEFAULT_STYLE, resolveStyle, type StyleSheet, type TextProps } from '../styles/styleSheet'; +import { builtinStyleSheet, DEFAULT_STYLE, resolveStyle, type StyleSheet, type TextProps, type ParaProps } from '../styles/styleSheet'; import { HEADING_STYLE_OVERRIDES, HEADING_FONT, HEADING_LEVELS, MAX_HEADING_LEVEL } from '../styles/headings'; import { TABLE_REGIONS, parseTableLook, regionText, type TableLook, type TableRegion, @@ -71,7 +71,6 @@ const ODFKIT_DEFAULT_FONT = 'Liberation Serif'; const EXPORT_FONT = 'Times New Roman'; // The body size a run without one of its own renders at (LibreOffice's default). const DEFAULT_FONT_SIZE_PT = 12; -const DEFAULT_LINE_HEIGHT = 1; // must match line-height multiplier default in ToolbarExpanded.svelte // Sentinel between a cell's blocks (and list items) so the single odf-kit // emits per cell can be split back into real blocks in applyCellBlocks. A private-use @@ -237,9 +236,10 @@ export function twinFontName(family: string): string { return family; } -// ODF encodes spaces in style names as _20_ ("Heading 1" → "Heading_20_1"). +// LibreOffice's style-name encoding: a style:name is an NCName, so every other character +// travels as its hex code ("Heading 1" → "Heading_20_1", "&" → "_26_"). export function odfStyleName(name: string): string { - return name.replace(/ /g, '_20_'); + return name.replace(/[^\p{L}\p{N}.-]/gu, (c) => `_${c.codePointAt(0)!.toString(16)}_`); } // The style name a block carries: its own, else the node type's default. Mirrors @@ -1192,14 +1192,21 @@ function replaceTableOfContents(doc: TiptapNode, tocs: TocExport[]): TiptapNode return { ...doc, content }; } +function unescapeXml(s: string): string { + return s.replace(/&(amp|lt|gt|quot|apos);/g, (_m, e: string) => + ({ amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" })[e]!); +} + function escapeXml(s: string): string { return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } // Mirror odf-kit's normalizeLineHeight (content.js): a number is a multiplier // (1.5 → "150%"); a string with a unit passes through ("18pt" → "18pt"). +// A proportional spacing is a bare factor, as a number or as the string a style holds. function normalizeLineHeight(lh: number | string): string { - return typeof lh === 'number' ? `${Math.round(lh * 100)}%` : lh; + const n = typeof lh === 'number' ? lh : Number(lh); + return Number.isFinite(n) ? `${Math.round(n * 100)}%` : String(lh); } // Paragraph property overrides for the exported .odt, for both list-item and @@ -1821,14 +1828,18 @@ function upsertProps(block: string, kind: 'paragraph' | 'text', attrs: Record; text: Record }) { +function ownStyleAttrs(style: { para: Record; text: Record }, inherited: ParaProps = {}) { const p = style.para as Record; const t = style.text as Record; const para: Record = {}; if (p.textAlign) para['fo:text-align'] = String(p.textAlign); if (p.lineHeight != null) para['fo:line-height'] = normalizeLineHeight(p.lineHeight as string | number); - if (p.spaceBefore != null) para['fo:margin-top'] = `${p.spaceBefore}pt`; - if (p.spaceAfter != null) para['fo:margin-bottom'] = `${p.spaceAfter}pt`; + // LibreOffice keeps the two vertical margins in one item: a style declaring only one + // takes the default style's other, not its parent's, so the inherited one is spelled out. + const before = p.spaceBefore ?? (p.spaceAfter != null ? inherited.spaceBefore ?? 0 : undefined); + const after = p.spaceAfter ?? (p.spaceBefore != null ? inherited.spaceAfter ?? 0 : undefined); + if (before != null) para['fo:margin-top'] = `${before}pt`; + if (after != null) para['fo:margin-bottom'] = `${after}pt`; if (p.indent != null) para['fo:margin-left'] = `${p.indent}cm`; if (p.backgroundColor) para['fo:background-color'] = String(p.backgroundColor); for (const [key, side] of [['borderTop', 'top'], ['borderRight', 'right'], ['borderBottom', 'bottom'], ['borderLeft', 'left']] as const) { @@ -1893,7 +1904,7 @@ function applyNamedStyles(styles: string, sheet: StyleSheet, used: Set, const odfName = odfStyleName(name); if (findAutoStyle(styles, odfName)) continue; added.push(setTagAttrs('', { - 'style:name': odfName, 'style:family': 'table', 'style:display-name': name, + 'style:name': odfName, 'style:family': 'table', 'style:display-name': escapeXml(name), })); } // List styles are their own element, not a style:style family; the referenced ones @@ -1910,14 +1921,14 @@ function applyNamedStyles(styles: string, sheet: StyleSheet, used: Set, const odfName = odfStyleName(style.name); if (findAutoStyle(styles, odfName)) continue; const attrs: Record = { - 'style:name': odfName, 'style:family': 'text', 'style:display-name': style.name, + 'style:name': odfName, 'style:family': 'text', 'style:display-name': escapeXml(style.name), }; added.push(upsertProps(`${setTagAttrs('', attrs).replace('/>', '>')}`, 'text', text)); } for (const style of Object.values(sheet.paragraph)) { if (!used.has(style.name)) continue; const odfName = odfStyleName(style.name); - const { para, text } = ownStyleAttrs(style); + const { para, text } = ownStyleAttrs(style, style.parent ? resolveStyle(sheet, style.parent, 'paragraph').para : {}); const existing = findAutoStyle(styles, odfName); if (existing) { let block = stripManagedProps(existing); @@ -1928,7 +1939,7 @@ function applyNamedStyles(styles: string, sheet: StyleSheet, used: Set, continue; } const attrs: Record = { - 'style:name': odfName, 'style:family': 'paragraph', 'style:display-name': style.name, + 'style:name': odfName, 'style:family': 'paragraph', 'style:display-name': escapeXml(style.name), }; if (style.parent) attrs['style:parent-style-name'] = odfStyleName(style.parent); if (style.next) attrs['style:next-style-name'] = odfStyleName(style.next); @@ -1997,7 +2008,8 @@ function applyParagraphStyles(odtBytes: Uint8Array): Uint8Array { let counter = 0; const styleFor = (source: string, styleName: string): string => { - const odfName = odfStyleName(styleName); + // The sentinel carries the name as serialized text, so its XML escapes come off first. + const odfName = odfStyleName(unescapeXml(styleName)); const auto = source ? findAutoStyle(content, source) : null; if (!auto) return odfName; // no direct formatting: reference the style itself const key = `${source}|${odfName}`; @@ -2997,19 +3009,22 @@ function applyWatermarkOdf(odtBytes: Uint8Array, wm: Watermark | null): Uint8Arr // decor on those pages. A document with no header gets one that reserves no band of // its own (LibreOffice still floors the band at 0.499cm, as for its own watermark). function injectIntoHeaderZones(styles: string, shapes: string): string { - if (!/]/.test(styles)) { - const zone = `${shapes}`; - const out = styles.replace(//, ''); - return /]*\/>/.test(out) - ? out.replace(/(]*)\/>/, `$1>${zone}`) - : out.replace(/(]*>)/, `$1${zone}`); - } - return styles.replace(/|(\s*]*>)?/g, (m) => { - if (m.endsWith('/>')) { - const tag = /style:header(?:-first|-left)?/.exec(m)![0]; - return `<${tag}>${shapes}`; + const blank = `${shapes}`; + const out = styles.replace(//g, ''); + // Every master page, the section masters included: the decor shows on their pages too. + return out.replace(/]*(?:\/>|>[\s\S]*?<\/style:master-page>)/g, (master) => { + if (!/]/.test(master)) { + return master.endsWith('/>') + ? master.replace(/\/>$/, `>${blank}`) + : master.replace(/(]*>)/, `$1${blank}`); } - return m.includes('${shapes}`; + return master.replace(/|(\s*]*>)?/g, (m) => { + const tag = /style:header(?:-first|-left)?/.exec(m)![0]; + if (/^$/.test(m)) return `<${tag}>${shapes}`; + if (!m.includes('${shapes}`; + // A blank zone's paragraph is self-closing; the shape needs it open. + return m.endsWith('/>') ? `${m.slice(0, -2)}>${shapes}` : `${m}${shapes}`; + }); }); } @@ -3062,6 +3077,20 @@ function lengthOfPageLayout(styles: string): number { return width ? width - num('fo:margin-left') - num('fo:margin-right') : 17; } +// LibreOffice's two flags for a numbered bibliography — [1] before each row, rows in +// citation order — live on the one text:bibliography-configuration in office:styles. +function applyBibliographyConfig(odtBytes: Uint8Array, numbered: boolean): Uint8Array { + if (!numbered) return odtBytes; + const files = unzipSync(odtBytes); + const stylesBytes = files['styles.xml']; + if (!stylesBytes) return odtBytes; + const styles = strFromU8(stylesBytes); + if (styles.includes('', + '')); + return rezipOdt(files); +} + function rewriteStylesXml(odtBytes: Uint8Array, lang: { language: string; country: string } | null, pageFormat: PageFormat, orientation: Orientation, sheet: StyleSheet, used: Set, usedTables: Set = new Set(), usedLists: Set = new Set(), tabIntervalCm: number = DEFAULT_TAB_INTERVAL_CM, mirrored = false, rtl = false, notes: NoteSettings = DEFAULT_NOTE_SETTINGS, hyphenate = false, pageNumbering: PageNumbering = DEFAULT_PAGE_NUMBERING, decor: PageDecor = EMPTY_PAGE_DECOR, lineNumbering: LineNumbering = DEFAULT_LINE_NUMBERING): Uint8Array { const files = unzipSync(odtBytes); const stylesBytes = files['styles.xml']; @@ -3092,7 +3121,7 @@ function rewriteStylesXml(odtBytes: Uint8Array, lang: { language: string; countr // the editor and Word (w:pgBorders offsetFrom="text") keep the text at the margins and // push the border out. Shrink the margins by border + padding so the text stays put. if (decor.border) { - const inset = decor.border.paddingCm + (decor.border.widthPt * 2.54) / 72; + const inset = borderInsetCm(decor); styles = styles.replace(/]*/, (m) => m.replace(/fo:margin-(top|bottom|left|right)="([\d.]+)cm"/g, (_a, side, v) => `fo:margin-${side}="${Math.max(0, round3(Number(v) - inset))}cm"`)); @@ -3458,7 +3487,7 @@ function applyCharacterStyles(odtBytes: Uint8Array): Uint8Array { let counter = 0; const styleFor = (source: string, styleName: string): string => { - const odfName = odfStyleName(styleName); + const odfName = odfStyleName(unescapeXml(styleName)); const auto = source ? findAutoStyle(content, source) : null; if (!auto) return odfName; const key = `${source}|${odfName}`; @@ -4593,10 +4622,10 @@ function tocXml(toc: TocExport, index: number, bibTypes: string[]): string { ``; // An alphabetical index is fed by its marks, is single-level, and merges the pages of // a term the reader marked more than once — LibreOffice's text:combine-entries. + // A numbered bibliography's two flags ride text:bibliography-configuration in + // office:styles (applyBibliographyConfig), the source element takes none. const sourceAttrs = toc.kind === 'bibliography' - // LibreOffice's own two flags for a numbered bibliography: [1] before each row, and - // the rows in the order the document cites them rather than sorted. - ? (toc.citationStyle === 'numbered' ? ' text:numbered-entries="true" text:sort-by-position="true"' : '') + ? '' : toc.kind === 'alphabetical' ? ' text:combine-entries="true" text:ignore-case="true"' : spec.seq @@ -4943,12 +4972,11 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU indentFirst?: string; tabStops?: { position: string; type: 'left' | 'center' | 'right' }[]; } = {}; + // A block with no line height of its own inherits its style's, so none is written. if (node.attrs?.lineHeight != null) { const lhRaw = String(node.attrs.lineHeight); const lhNum = parseFloat(lhRaw); opts.lineHeight = isNaN(lhNum) ? lhRaw : lhNum; - } else { - opts.lineHeight = DEFAULT_LINE_HEIGHT; } const ta = node.attrs?.textAlign; if (ta === 'left' || ta === 'center' || ta === 'right' || ta === 'justify') { @@ -5089,12 +5117,15 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU const withNamedStyles = applyCharacterStyles(applyTextEffects(applyParagraphStyles(withParaBoxes))); const usedTables = new Set(tableStyleNames.filter((t): t is TableStyleRef => !!t).map(t => t.name)); const withStyles = rewriteStylesXml(withNamedStyles, language ?? null, pageFormat, orientation, styles, usedStyleNames(docJson, styles), usedTables, new Set(listStyleRepoints.filter((n): n is string => !!n)), tabIntervalCm, margins.mirrored === true, rtl, notesSettings, hyphenate, pageNumbering, decor, lineNumbering); - const withHf = applyHfPostProcess(withStyles, margins, headerPara, footerPara, headerDist, footerDist, firstHeaderPara, firstFooterPara, hf?.pageCount ?? 1, hfImages, evenHeaderPara, evenFooterPara); - const withWatermark = applyFoldMarksOdf(applyWatermarkOdf(withHf, decor.watermark), foldMarks); + const withBib = applyBibliographyConfig(withStyles, tocs.some((t) => t.kind === 'bibliography' && t.citationStyle === 'numbered')); + const withHf = applyHfPostProcess(withBib, margins, headerPara, footerPara, headerDist, footerDist, firstHeaderPara, firstFooterPara, hf?.pageCount ?? 1, hfImages, evenHeaderPara, evenFooterPara); // Sections past the first get their own master page, which is where ODF keeps a - // section's header/footer; the SEC-marked block points at it. - const withSections = applySectionMasterPages(withWatermark, hf?.sections ?? [], hf?.pageCount ?? 1, margins, pageFormat, orientation); - return zipFinal(applyOdfVersion(applyDocProperties(applyPageNumberStart(applySpacingModel(withSections, spacingModel, spacingAtPageStart), pageNumbering.start), props))); + // section's header/footer; the SEC-marked block points at it. The page decor goes + // into every master's header after that, so the section pages show it too. + const withSections = applySectionMasterPages(withHf, hf?.sections ?? [], hf?.pageCount ?? 1, margins, pageFormat, orientation, + { header: !!headerPara, footer: !!footerPara }, { header: headerDist, footer: footerDist }, borderInsetCm(decor)); + const withWatermark = applyFoldMarksOdf(applyWatermarkOdf(withSections, decor.watermark), foldMarks); + return zipFinal(applyOdfVersion(applyDocProperties(applyPageNumberStart(applySpacingModel(withWatermark, spacingModel, spacingAtPageStart), pageNumbering.start), props))); } // The package declares ODF 1.3 in every part — the version LibreOffice writes, and @@ -5120,9 +5151,12 @@ function applyPageNumberStart(odtBytes: Uint8Array, start: number): Uint8Array { const content = strFromU8(contentBytes); const body = /]*>/.exec(content); if (!body) return odtBytes; - const first = /]*)>/.exec(content.slice(body.index + body[0].length)); + // Past the tracked-changes registry, whose paragraphs are deleted text. + const registryEnd = content.indexOf('', body.index); + const from = registryEnd >= 0 ? registryEnd + ''.length : body.index + body[0].length; + const first = /]*)>/.exec(content.slice(from)); if (!first) return odtBytes; - const at = body.index + body[0].length + first.index; + const at = from + first.index; const srcM = /text:style-name="([^"]*)"/.exec(first[2]); const source = srcM ? srcM[1] : 'Standard'; const name = 'PgNumStart'; @@ -5270,12 +5304,16 @@ function applyHfPostProcess(odtBytes: Uint8Array, margins: PageMargins, headerPa new RegExp(`[\\s\\S]*?`), ``, ); + // On the zone's own paragraph, not the shared Header/Footer style: the first-page + // and even-page variants inherit that style and have alignments of their own. const props = hfParaPropsXml(para); if (props) { - const styleName = kind === 'header' ? 'Header' : 'Footer'; + const parent = kind === 'header' ? 'Header' : 'Footer'; + const name = `HFD${kind[0].toUpperCase()}P`; + mint(`${props}`); styles = styles.replace( - new RegExp(`(]*?)/>`), - `$1>${props}`, + new RegExp(`(\\s* void, pfx: string): string { - const zone = (kind: 'header' | 'footer', suffix: 'first' | 'left' | null, doc: HfDoc): string => { - if (hfIsEmpty(doc)) return ''; - return hfVariantZoneXml(kind, suffix, doc!.content![0] as TiptapNode, pageCount, mint, pfx); + // A variant and its running zone travel as a pair: ODF allows a first/left zone only + // under a running one, and an absent left zone repeats the running one. Where either + // is set both are written, the blank one as an empty paragraph, which blanks its side. + const zone = (kind: 'header' | 'footer', suffix: 'first' | 'left' | null, doc: HfDoc, force = false): string => { + if (!force && hfIsEmpty(doc)) return ''; + const para = hfIsEmpty(doc) ? { type: 'paragraph', content: [] } : doc!.content![0]; + return hfVariantZoneXml(kind, suffix, para as TiptapNode, pageCount, mint, pfx); }; - // An empty default beside a variant blanks its side, as the editor renders it. - const need = (a: HfDoc, b: HfDoc) => (hfIsEmpty(a) && !hfIsEmpty(b) ? { type: 'doc', content: [{ type: 'paragraph', content: [] }] } as HfDoc : a); - const hFirst = set.differentFirstPage ? set.headerFirst : null; - const fFirst = set.differentFirstPage ? set.footerFirst : null; - const hEven = set.differentOddEven ? set.headerEven : null; - const fEven = set.differentOddEven ? set.footerEven : null; - const body = zone('header', null, need(need(set.header, hFirst), hEven)) - + zone('header', 'first', hFirst) + zone('header', 'left', hEven) - + zone('footer', null, need(need(set.footer, fFirst), fEven)) - + zone('footer', 'first', fFirst) + zone('footer', 'left', fEven); + const on = (flag: boolean, running: HfDoc, variant: HfDoc) => flag && (!hfIsEmpty(running) || !hfIsEmpty(variant)); + const hFirst = on(set.differentFirstPage, set.header, set.headerFirst); + const hEven = on(set.differentOddEven, set.header, set.headerEven); + const fFirst = on(set.differentFirstPage, set.footer, set.footerFirst); + const fEven = on(set.differentOddEven, set.footer, set.footerEven); + // In the schema's order: the running zone, its left variant, then its first-page one. + const body = zone('header', null, set.header, hFirst || hEven) + + (hEven ? zone('header', 'left', set.headerEven, true) : '') + + (hFirst ? zone('header', 'first', set.headerFirst, true) : '') + + zone('footer', null, set.footer, fFirst || fEven) + + (fEven ? zone('footer', 'left', set.footerEven, true) : '') + + (fFirst ? zone('footer', 'first', set.footerFirst, true) : ''); return `${body}`; } +// What a page border and its padding take off every page margin (they sit inside it). +function borderInsetCm(decor: PageDecor): number { + return decor.border ? decor.border.paddingCm + (decor.border.widthPt * 2.54) / 72 : 0; +} + // Point each SEC-marked block at its section's master page (ODF's only per-section // header/footer), minting the master pages beside the Standard one odf-kit wrote. -function applySectionMasterPages(odtBytes: Uint8Array, sets: HfSet[], pageCount: number, margins: PageMargins, format: PageFormat, orientation: Orientation): Uint8Array { +// `docZones`/`dists` are the document's own running zones and edge→zone distances. +function applySectionMasterPages(odtBytes: Uint8Array, sets: HfSet[], pageCount: number, margins: PageMargins, format: PageFormat, orientation: Orientation, docZones: { header: boolean; footer: boolean }, dists: { header: number; footer: number }, insetCm: number): Uint8Array { const files = unzipSync(odtBytes); const contentBytes = files['content.xml']; const stylesBytes = files['styles.xml']; @@ -5445,46 +5495,53 @@ function applySectionMasterPages(odtBytes: Uint8Array, sets: HfSet[], pageCount: content = injectAutomaticStyles(content, minted.join('')); } - // The page layout odf-kit gave the Standard master page: reused as is by a section - // whose margins are the document's, cloned with them shifted by a section that has - // its own — a shift, so whatever the header/footer pass folded into it survives. + // The page layout odf-kit gave the Standard master, shared by a section whose page setup + // and zones are the document's; any other gets a clone built as applyHfPostProcess builds + // it: a side with a zone has the distance as page margin and a band up to the body margin. const layout = /]*style:page-layout-name="([^"]*)"/.exec(styles)?.[1] ?? 'pm1'; const layoutXml = new RegExp(`]*style:name="${layout}"[\\s\\S]*?`).exec(styles)?.[0] ?? null; const layouts: string[] = []; + const round3 = (v: number) => Math.round(v * 1000) / 1000; const layoutFor = (index: number, set: HfSet): string => { const m = set.margins; const paper = set.format || set.orientation ? pageDimsCm(set.format ?? format, set.orientation ?? orientation) : null; const numFormat = set.pageNumberFormat ?? null; - // ODF's page margin *is* the edge→zone distance on a side that has one, so a - // section with its own distances writes them outright rather than as a shift. - const dist = set.distances ?? null; - const distTop = dist && (set.header || set.headerFirst || set.headerEven) ? dist.header : null; - const distBottom = dist && (set.footer || set.footerFirst || set.footerEven) ? dist.footer : null; + const has = { header: !!(set.header || set.headerFirst || set.headerEven), footer: !!(set.footer || set.footerFirst || set.footerEven) }; + const sameZones = has.header === docZones.header && has.footer === docZones.footer && !set.distances; // The side the section opens on: page one is a right page whatever it says, so only // a later section carries it. const side = index > 0 && set.startsOn ? (set.startsOn === 'odd' ? 'right' : 'left') : null; - if ((!m && !paper && !numFormat && distTop == null && distBottom == null && !side) || !layoutXml) return layout; + if ((!m && !paper && !numFormat && sameZones && !side) || !layoutXml) return layout; + const body = m ?? margins; + const band = (kind: 'header' | 'footer', edge: 'top' | 'bottom'): { margin: number; minH: number | null } => { + if (!has[kind]) return { margin: body[edge], minH: null }; + // A section's own distance is written as it is: past the body margin, the body + // starts below the band there, as the editor lays it out. + const dist = set.distances?.[kind] ?? dists[kind]; + return { margin: dist, minH: Math.max(0.2, body[edge] - dist) }; + }; + const top = band('header', 'top'); + const bottom = band('footer', 'bottom'); + const bandXml = (kind: 'header' | 'footer', minH: number | null) => (minH == null + ? `` + : ``); const name = `${layout}Sec${index + 1}`; - layouts.push(layoutXml - .replace(/\s*style:page-usage="[^"]*"/, '') + let xml = (side ? layoutXml.replace(/\s*style:page-usage="[^"]*"/, '') : layoutXml) .replace(`style:name="${layout}"`, `style:name="${name}"${side ? ` style:page-usage="${side}"` : ''}`) .replace(/]*>/, (props) => { - // The margins are a shift, so whatever the header/footer pass folded into them - // survives; the paper is set outright. - let p = m - ? (['top', 'bottom', 'left', 'right'] as const).reduce((q, side) => - q.replace(new RegExp(`fo:margin-${side}="([\\d.]+)cm"`), (_x, cm: string) => - `fo:margin-${side}="${Math.max(0, Math.round((parseFloat(cm) + m[side] - margins[side]) * 1000) / 1000)}cm"`), props) - : props; + // The page border and its padding sit inside the margins (rewriteStylesXml). + const cm = (v: number) => `${Math.max(0, round3(v - insetCm))}cm`; + let p = props + .replace(/fo:margin-top="[^"]*"/, `fo:margin-top="${cm(top.margin)}"`) + .replace(/fo:margin-bottom="[^"]*"/, `fo:margin-bottom="${cm(bottom.margin)}"`) + .replace(/fo:margin-left="[^"]*"/, `fo:margin-left="${cm(body.left)}"`) + .replace(/fo:margin-right="[^"]*"/, `fo:margin-right="${cm(body.right)}"`); if (paper) { - const round3 = (v: number) => Math.round(v * 1000) / 1000; p = p .replace(/fo:page-width="[^"]*"/, `fo:page-width="${round3(paper.w)}cm"`) .replace(/fo:page-height="[^"]*"/, `fo:page-height="${round3(paper.h)}cm"`) .replace(/style:print-orientation="[^"]*"/, `style:print-orientation="${paper.w > paper.h ? 'landscape' : 'portrait'}"`); } - if (distTop != null) p = p.replace(/fo:margin-top="[^"]*"/, `fo:margin-top="${distTop}cm"`); - if (distBottom != null) p = p.replace(/fo:margin-bottom="[^"]*"/, `fo:margin-bottom="${distBottom}cm"`); // The page-number format rides the layout, which is where LibreOffice keeps it. if (numFormat) { p = /style:num-format="/.test(p) @@ -5492,7 +5549,11 @@ function applySectionMasterPages(odtBytes: Uint8Array, sets: HfSet[], pageCount: : p.replace(']*?(?:\/>|>[\s\S]*?<\/style:header-style>)/, '') + .replace(/]*?(?:\/>|>[\s\S]*?<\/style:footer-style>)/, ''); + xml = xml.replace('', `${bandXml('header', top.minH)}${bandXml('footer', bottom.minH)}`); + layouts.push(xml); return name; }; const hfStyles: string[] = []; diff --git a/src/lib/import/CLAUDE.md b/src/lib/import/CLAUDE.md index 15e24a8..2773957 100644 --- a/src/lib/import/CLAUDE.md +++ b/src/lib/import/CLAUDE.md @@ -14,6 +14,7 @@ The **Open .odt** button (`App.svelte`) parses an uploaded file with `importOdt( margins live in one item, so a style declaring only `fo:margin-top` (or only `-bottom`) takes the **default style's** value for the other, never the parent chain's (`layerParaProps`; probed — it is what makes a heading whose style sets only `fo:margin-bottom` sit flush). + The export therefore spells out the inherited margin beside a style's lone one (`ownStyleAttrs`). `fo:line-height` is stored as the ODF percentage itself (115% → `1.15`, as both DOCX paths and the export use it) and suppressed against the block's own style, so a 100% under a 115% style stays as direct formatting. @@ -32,7 +33,7 @@ The **Open .odt** button (`App.svelte`) parses an uploaded file with `importOdt( - **Content controls** (`inlineChildren`, DOCX): a `w:sdt` inside a paragraph — Word wraps every citation and bibliography entry in one — is walked through to its `w:sdtContent`, or the runs inside it never reach the paragraph (a fixture measured against LibreOffice lost every `[7]`-style reference and broke its lines one page short). The exception is one tagged `edentext-placeholder`: it survives whole as a `placeholderField` node, its export-minted gray stripped so the presentation color never becomes a mark. ODF's `text:placeholder` becomes the same node, the shown text's `<>`/`‹›` brackets stripped. - **Formulas** (`convertFormulaFrame`): a `` whose `` resolves to a sub-document with a `math:math` root (or holds one inline) → a `formula` node. Our own `` is preferred; LibreOffice strips it, so the MathML is parsed back to LaTeX instead. **`display` comes from the paragraph** (`aloneInParagraph`), not the object — LibreOffice writes `display="block"` on every formula it re-saves. DOCX: `m:oMath`/`m:oMathPara` sit beside the `w:r` runs in their own namespace, so `convertInline` probes them ahead of its `namespaceURI !== W` guard (that guard silently dropped every Word formula). See `docs/architecture/formulas.md`. - **Bookmarks & cross-references**: `text:bookmark-start`/`-end` (ODF) and `w:bookmarkStart`/`End` (DOCX) become a `bookmark` **mark** on the text they cover; the open ranges live in `Ctx` because Word writes them beside paragraphs as well as beside runs, and a range may close in a later paragraph. A **point** bookmark has no range to mark and is dropped, `_GoBack` with it. `text:bookmark-ref` and `REF`/`PAGEREF` fields (the `fldChar` machine and `w:fldSimple` alike, including a field Word left unresolved) become `crossRef` nodes carrying the producer's cached text. A `w:hyperlink w:anchor` with no `r:id` is an internal link → `#name`. See `docs/architecture/formatting.md`. -- **Page geometry is the *body's* master page**, not the one named `Standard`: a pre-pass over the body's paragraphs (`dominantMasterPage`) counts the blocks each `style:master-page-name` governs and geometry comes from the winner — **before** the walk, since the text width it yields is what a table's margins, an inline image's fit and the tab-stop suppression measure against (17.0cm against a real 14.7 on one file when it was elected after). A document whose Standard master no paragraph references would otherwise take margins — and a footer band — no page of it has. **Naming a master breaks the page** (LibreOffice's "page break with page style") — probed: even a style naming the master the page already uses breaks, which is how a book starts each chapter fresh, so only the document's first block is exempt. The *section* (its own header/footer set) starts only where the master really changes. An empty `style:master-page-name=""` is LibreOffice's explicit "no change" and ends the parent-chain walk, so an ancestor's master can't break every child style. `fo:break-after="page"` becomes the next block's `breakBefore`, the only break the editor stores. A master with a `style:next-style-name` is itself the different-first-page flag: its own (possibly absent) zones govern page one — **unless the two name each other**. A `next-style-name` cycle is LibreOffice's Left Page / Right Page, one mirrored setup rather than a hand-over: the right master's geometry governs, the left master's zones become the even variant, and `style:page-usage="left"`/`"right"` marks the margins `mirrored` **only where that twin really exists** — on a single-sided layout it means a page that starts on the right (`HfSet.startsOn`, which takes a blank page rather than the pair), and swapping its pair drew an index 9mm off. Reading it as a hand-over cost a book-style body its running head on every second page and its inner/outer margins with it. +- **The document's page setup is the first section's** — zones, margins, paper and edge distances of the master its leading block uses (`masterPagesOf`: `Standard` where it names none, and a table or list at the top starts on Standard too); a later section carries its own where they differ (`hfSetOfMasterPage`), so a chapter master's setup is kept as the section's. Blocks measure against their *own* section's text width (`Ctx.contentWidthCm`, moved on at every master switch — a table's margins, an inline image's fit, the tab-stop suppression; measured: 17.0cm against a real 14.7 on one file), an index's stop against the document's as well, where the export keeps it. **Naming a master breaks the page** (LibreOffice's "page break with page style") — probed: even a style naming the master the page already uses breaks, which is how a book starts each chapter fresh, so only the document's first block is exempt. The *section* (its own header/footer set) starts only where the master really changes. An empty `style:master-page-name=""` is LibreOffice's explicit "no change" and ends the parent-chain walk, so an ancestor's master can't break every child style. `fo:break-after="page"` becomes the next block's `breakBefore`, the only break the editor stores. A master with a `style:next-style-name` is itself the different-first-page flag: its own (possibly absent) zones govern page one — **unless the two name each other**. A `next-style-name` cycle is LibreOffice's Left Page / Right Page, one mirrored setup rather than a hand-over: the right master's geometry governs, the left master's zones become the even variant, and `style:page-usage="left"`/`"right"` marks the margins `mirrored` **only where that twin really exists** — on a single-sided layout it means a page that starts on the right (`HfSet.startsOn`, which takes a blank page rather than the pair), and swapping its pair drew an index 9mm off. Reading it as a hand-over cost a book-style body its running head on every second page and its inner/outer margins with it. - **A page-anchored frame** (`text:anchor-type="page"`) is out of the flow: it rides a paragraph of its own that collapses to nothing, and the node view places it from its page's corner (`anchorPage`, `image.ts`) — that paragraph must stay `position: static` (`editor.css`, `!important` over the line-height rule's own `position: relative`), or it becomes the frame's containing block instead of the page and offsets it by a page margin. Behind the text by default (`style:run-through="background"`); a cover page's own graphic instead declares `"foreground"` (`inFront` attr) to sit in front and hide what the page hoists behind it (a redundant logo, a title paragraph the graphic already carries). In a cell it falls back to an ordinary floating frame. - **A header/footer zone is fitted to its schema** (`fitZoneSchema`): the zone editor has fewer marks and nodes than the body, and one it doesn't know makes `generateHTML` throw — which renders the whole zone blank. Unknown marks are dropped, unknown nodes leave their text. - **An as-char text box is placed by its paragraph**, which is where its alignment stays — the box keeps only the `style:horizontal-pos` a band-wrapped frame declares of its own. Its `fo:padding` is read too — the editor's own 0.15cm ring is only the default. diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index d303512..8c3a743 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -214,6 +214,10 @@ export function importDocx(bytes: Uint8Array, convertedImages: ConvertedImages = && !sectionStartsNewPage(g.sectPr ?? finalSectPr) && JSON.stringify(groupCols[gi]) !== JSON.stringify(groupCols[gi - 1])); groups.forEach((g, gi) => { + // Blocks measure against their own section's text width. + const sect = g.sectPr ?? finalSectPr; + ctx.contentWidthCm = sectionContentWidthCm(sect); + ctx.leftMarginCm = twipToCm(intAttr(fc(sect, 'pgMar'), W, 'left') ?? 1440); const inner = convertBlocks(g.els, ctx, 'body'); // A section's own w:type says how it begins: a page-starting break (nextPage/odd/even, // or the default) puts its first block on a new page; continuous/nextColumn flow on. @@ -248,6 +252,8 @@ export function importDocx(bytes: Uint8Array, convertedImages: ConvertedImages = // section's, since that is the pair .tiptap's padding draws and every later section is // measured against (Editor.svelte's --pb-section-inset). const sect = parseSectPr(finalSectPr, ctx, oddEven); + // The edge→zone distances are the first section's too, as its zones are. + const firstSect = parseSectPr(groups[0]?.sectPr ?? finalSectPr, ctx, oddEven); // The paper is the *first* section's, as the margins are: a document whose last // section is one landscape page is not a landscape document. const docPaper = sectPaper(groups[0]?.sectPr ?? finalSectPr); @@ -292,8 +298,8 @@ export function importDocx(bytes: Uint8Array, convertedImages: ConvertedImages = footerEven: first.differentOddEven ? first.footerEven : null, differentOddEven: first.differentOddEven, hfSections, - headerDistanceCm: hasHeader ? sect.headerDistCm : null, - footerDistanceCm: hasFooter ? sect.footerDistCm : null, + headerDistanceCm: hasHeader ? firstSect.headerDistCm : null, + footerDistanceCm: hasFooter ? firstSect.footerDistCm : null, language: documentLanguage(stylesDoc, warnings), props: docxDocProperties(files), fonts: extractDocxFonts(files), @@ -1026,7 +1032,7 @@ function collectStyleSheet(ctx: Ctx): StyleSheet { const style: Style = { name, parent: parent && parent !== name ? parent : builtin?.parent ?? null, - next: builtin?.next ?? null, + next: def.next ? ctx.styleNames.get(def.next) ?? def.next : builtin?.next ?? null, builtin: builtin?.builtin, para: ownProps(stylePara(ctx, id), stylePara(ctx, parentId)), text: ownProps(styleText(ctx, id), styleText(ctx, parentId)), diff --git a/src/lib/import/docxStyles.ts b/src/lib/import/docxStyles.ts index f7d1718..9ef8db9 100644 --- a/src/lib/import/docxStyles.ts +++ b/src/lib/import/docxStyles.ts @@ -169,6 +169,7 @@ export class DocxStyles { private defaultsRun: RunProps = {}; private ownRun = new Map(); // styleId → own w:rPr private basedOn = new Map(); + private next = new Map(); // w:next private memoOwn = new Map(); private styleNum = new Map(); private ownOutline = new Map(); // style's own w:outlineLvl (heading marker) @@ -240,6 +241,7 @@ export class DocxStyles { const id = style.getAttributeNS(W, 'styleId'); if (!id) continue; this.basedOn.set(id, firstChild(style, 'basedOn') ? wVal(firstChild(style, 'basedOn')!) : null); + this.next.set(id, firstChild(style, 'next') ? wVal(firstChild(style, 'next')!) : null); this.ownRun.set(id, parseRunProps(firstChild(style, 'rPr'))); if (style.getAttributeNS(W, 'type') === 'paragraph' && (style.getAttributeNS(W, 'default') === '1' || style.getAttributeNS(W, 'default') === 'true')) { this.defaultParaStyle = id; @@ -310,9 +312,9 @@ export class DocxStyles { } // Paragraph styles defined in the file: display name and parent, for the style registry. - namedParagraphStyles(): Map { - const out = new Map(); - for (const [id, name] of this.paraStyleNames) out.set(id, { name, basedOn: this.basedOn.get(id) ?? null }); + namedParagraphStyles(): Map { + const out = new Map(); + for (const [id, name] of this.paraStyleNames) out.set(id, { name, basedOn: this.basedOn.get(id) ?? null, next: this.next.get(id) ?? null }); return out; } diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index 61c5b41..0c1a7e1 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -143,8 +143,10 @@ type Ctx = { files: Record; imageCache: Map; convertedImages: ConvertedImages; - // Text width (cm) of the file's page setup; a table's margins are relative to it. + // Text width (cm) of the section being walked; a table's margins are relative to it. + // `docContentWidthCm` is the first section's, where the export keeps an index's stop. contentWidthCm: number; + docContentWidthCm: number; // The page's own direction: a block declaring the same one is inheriting, not // formatted, so only a block that differs carries a `dir` attr. pageRtl: boolean; @@ -789,15 +791,15 @@ export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages = for (const [name, def] of resolver.namedParagraphStyles()) styleNames.set(name, displayStyleName(name, def.display)); const charStyleNames = new Map(); for (const [name, def] of resolver.namedTextStyles()) charStyleNames.set(name, displayStyleName(name, def.display)); - // Page geometry is document-wide, so it comes from the master governing most of the - // body — elected before the walk, because the text width it yields is what a table's - // margins, an inline image's fit and the tab-stop suppression measure against. - resolver.setDefaultMaster(dominantMasterPage(Array.from(body.children), resolver)); - const geo = resolver.pageGeometry(); - const contentWidthCm = geo - ? pageDimsCm(geo.format, geo.orientation).w - geo.margins.left - geo.margins.right - : pageDimsCm('A4', 'portrait').w - 2 * 2.12; - const ctx: Ctx = { resolver, styleNames, usedStyles: new Set(), charStyleNames, usedCharStyles: new Set(), usedListStyles: new Set(), warnings, files, imageCache: new Map(), convertedImages, contentWidthCm, pageRtl: geo?.rtl ?? false, masterPages: [], masterPageStarts: [], bodyBlocks: 0, openBookmarks: new Set(), openComments: new Map(), commentReplies: odfCommentReplies(body), revisions: odfRevisions(body), openInsertions: new Map(), notes: [], foldMarks: false }; + // The document's page setup is the first section's: the master its leading block + // uses, Standard where it names none. Blocks measure against their own section's text + // width (a table's margins, an inline image's fit, the tab-stop suppression). + const masters = masterPagesOf(Array.from(body.children), resolver); + resolver.setDefaultMaster(masters.dominant); + const first = resolver.hasMasterPage(masters.leading) ? masters.leading : null; + const geo = resolver.pageGeometry(first) ?? resolver.pageGeometry(); + const contentWidthCm = contentWidthOf(geo); + const ctx: Ctx = { resolver, styleNames, usedStyles: new Set(), charStyleNames, usedCharStyles: new Set(), usedListStyles: new Set(), warnings, files, imageCache: new Map(), convertedImages, contentWidthCm, docContentWidthCm: contentWidthCm, pageRtl: geo?.rtl ?? false, masterPages: [], masterPageStarts: [], bodyBlocks: 0, openBookmarks: new Set(), openComments: new Map(), commentReplies: odfCommentReplies(body), revisions: odfRevisions(body), openInsertions: new Map(), notes: [], foldMarks: false }; let blocks = convertBlocks(Array.from(body.children), ctx, 'body'); if (blocks.length === 0) blocks.push({ type: 'paragraph' }); pairAlignedFrames(blocks, Math.floor(cmToPx(contentWidthCm))); @@ -821,9 +823,11 @@ export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages = })) }); } - const hf = resolver.masterPageHF(); + // The zones are the first section's too; a later section carries its own setup where + // it differs (hfSetOfMasterPage), so a chapter master's is not lost either. + const hf = resolver.masterPageHF(first); const geometry = geo; - const edge = resolver.edgeDistancesCm(); + const edge = resolver.edgeDistancesCm(first); const fonts: EmbeddedFont[] = []; for (const s of resolver.embeddedFontSources()) { @@ -844,7 +848,7 @@ export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages = } } - const docNumFormat = resolver.pageNumberFormat(); + const docNumFormat = resolver.pageNumberFormat(first); const headerFirst = hf.headerFirst ? convertHfZone(hf.headerFirst, ctx, hf.headerBandCm) : null; const footerFirst = hf.footerFirst ? convertHfZone(hf.footerFirst, ctx, hf.footerBandCm, true) : null; // The presence of a first-page element is the flag, even when it's empty (an empty @@ -867,7 +871,7 @@ export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages = orientation: geometry?.orientation ?? null, format: geometry?.format ?? null, rtl: geometry?.rtl ?? false, - decor: resolver.pageDecor(), + decor: resolver.pageDecor(first), lineNumbering: resolver.lineNumbering(), foldMarks: ctx.foldMarks, hyphenate: resolver.documentHyphenation(), @@ -1097,16 +1101,31 @@ function mergeHfBox(maps: Record[]): Record { return out; } +// The text width of a page setup; the ODF default page where the file declares none. +function contentWidthOf(geo: { margins: PageMargins; orientation: Orientation; format: PageFormat } | null): number { + return geo + ? pageDimsCm(geo.format, geo.orientation).w - geo.margins.left - geo.margins.right + : pageDimsCm('A4', 'portrait').w - 2 * 2.12; +} + // The document's first page number: ODF has no document-level start, so LibreOffice puts // `style:page-number` on the first paragraph's own style. function odfPageNumberStart(resolver: StyleResolver, body: Element): number { - for (const el of Array.from(body.children)) { - if (el.namespaceURI !== NS.text || (el.localName !== 'p' && el.localName !== 'h')) continue; - const style = el.getAttributeNS(NS.text, 'style-name'); - const n = style ? Number(resolver.paraProps(style)['style:page-number']) : NaN; - return Number.isFinite(n) ? clampPageStart(n) : 1; + const style = firstParagraph(body)?.getAttributeNS(NS.text, 'style-name'); + const n = style ? Number(resolver.paraProps(style)['style:page-number']) : NaN; + return Number.isFinite(n) ? clampPageStart(n) : 1; +} + +// The first paragraph in document order, wherever the body keeps it (a section, a list, +// a table) — past the tracked-changes registry, whose paragraphs are deleted text. +function firstParagraph(el: Element): Element | null { + for (const child of Array.from(el.children)) { + if (child.namespaceURI === NS.text && child.localName === 'tracked-changes') continue; + if (child.namespaceURI === NS.text && (child.localName === 'p' || child.localName === 'h')) return child; + const inner = firstParagraph(child); + if (inner) return inner; } - return 1; + return null; } // meta.xml → the document's descriptive properties (LibreOffice's File ▸ Properties). @@ -1324,7 +1343,10 @@ function convertToc(el: Element, ctx: Ctx, indexKind: IndexKind): Node { let tabPosCm = lengthToCm(stop?.getAttributeNS(NS.style, 'position')) ?? [...styled].reverse().find(t => t.align === 'right')?.pos ?? null; // A stop at the column's end is the attr's own default (null = the text width). - if (tabPosCm != null && Math.abs(tabPosCm - ctx.contentWidthCm) < 0.05) tabPosCm = null; + // ponytail: the entry styles are one set per document, so an index in a section of other + // margins carries the document's stop; per-index entry styles would name its own. + const atEnd = (w: number) => tabPosCm != null && Math.abs(tabPosCm - w) < 0.05; + if (atEnd(ctx.contentWidthCm) || atEnd(ctx.docContentWidthCm)) tabPosCm = null; // Each level's own paragraph style: the entry rows carry its name, so the document // stylesheet gives them the file's indent, spacing and font instead of our defaults. const levelStyles: (string | null)[] = []; @@ -1356,7 +1378,7 @@ function convertToc(el: Element, ctx: Ctx, indexKind: IndexKind): Node { const styles = templates.map((tpl) => citationStyleFromTemplate( Array.from(tpl.getElementsByTagNameNS(NS.text, 'index-entry-bibliography')) .map((f) => f.getAttributeNS(NS.text, 'bibliography-data-field') ?? ''))); - attrs.citationStyle = source?.getAttributeNS(NS.text, 'numbered-entries') === 'true' + attrs.citationStyle = ctx.resolver.bibliographyNumbered() || source?.getAttributeNS(NS.text, 'numbered-entries') === 'true' ? 'numbered' : styles.find((st) => st && st !== 'key') ?? 'key'; } @@ -1406,6 +1428,8 @@ type BlockDefaults = { underline: boolean; strike: boolean; caps: CapsMode | null; + // The style's own paragraph background and rule lines (paraBoxAttrs). + box: Record; }; // Metric twins: the on-screen font and the name we declare in files mean the same thing. @@ -1451,6 +1475,7 @@ function blockDefaults(resolver: StyleResolver, named: string | null, headingLev underline: false, strike: false, caps: null, + box: {}, }; if (!named) return fallback; const para = resolver.paraProps(named); @@ -1479,6 +1504,7 @@ function blockDefaults(resolver: StyleResolver, named: string | null, headingLev underline: !!text['style:text-underline-style'] && text['style:text-underline-style'] !== 'none', strike: !!text['style:text-line-through-style'] && text['style:text-line-through-style'] !== 'none', caps: capsFromOdf(text), + box: paraBoxAttrs(para), }; } @@ -1490,9 +1516,14 @@ function screenFontName(family: string): string { return family; } -// ODF encodes spaces as _20_; a style:display-name (when present) is authoritative. +// A style:display-name (when present) is authoritative; else the name's _hex_ escapes +// (LibreOffice's encoding of what an NCName cannot hold, "_20_" for a space) decode. function displayStyleName(odfName: string, display?: string): string { - return display || odfName.replace(/_20_/g, ' '); + return display || decodeStyleName(odfName); +} + +export function decodeStyleName(odfName: string): string { + return odfName.replace(/_([0-9a-fA-F]{2,6})_/g, (_m, hex: string) => String.fromCodePoint(parseInt(hex, 16))); } function paraPropsFromOdf(props: PropMap): ParaProps { @@ -1592,7 +1623,7 @@ function collectStyleSheet(resolver: StyleResolver, ctx: Ctx): StyleSheet { const style: Style = { name, parent: parent && parent !== name ? parent : builtin?.parent ?? null, - next: builtin?.next ?? null, + next: def.next ? displayStyleName(def.next, defs.get(def.next)?.display) : builtin?.next ?? null, builtin: builtin?.builtin, para: ownProps(paraPropsFromOdf(resolver.paraProps(odfName)), paraPropsFromOdf(def.parent ? resolver.paraProps(def.parent) : {})), text: ownProps(textPropsFromOdf(resolver.paraTextProps(odfName), resolver), textPropsFromOdf(def.parent ? resolver.paraTextProps(def.parent) : {}, resolver)), @@ -1775,23 +1806,27 @@ function convertParaLike(el: Element, ctx: Ctx, kind: BlockKind, boldByDefault = // heading), so the file's own margins there are direct formatting. if (kind === 'cell' && !isHeading) { defaults.marginTopPt = 0; defaults.marginBottomPt = 0; } + // A style:master-page-name switches the page master, which is how ODF gives a section + // its own header/footer; the block that does it opens that section, and from it on + // the blocks measure against that master's text width. + const master = kind === 'body' ? resolver.masterPageOf(styleName) : null; + const opensSection = !!master && ctx.masterPages[ctx.masterPages.length - 1] !== master; + if (opensSection) { + ctx.masterPages.push(master!); + // The same paragraph carries the number the section restarts at, if it does. + const restart = Number(paraProps['style:page-number']); + ctx.masterPageStarts.push(Number.isFinite(restart) ? clampPageStart(restart) : null); + const geo = resolver.pageGeometry(master!); + if (geo) ctx.contentWidthCm = contentWidthOf(geo); + } const attrs = blockAttrs(paraProps, baseTextProps, defaults, kind); if (paraProps['style:contextual-spacing'] === 'true') applyContextualSpacing(el, styleName, attrs); - // A style:master-page-name switches the page master, which is how ODF gives a section - // its own header/footer; the block that does it opens that section. - const master = kind === 'body' ? resolver.masterPageOf(styleName) : null; if (master) { // Naming a master *is* a page break, even where the page already uses that one // (probed) — which is how a book starts every chapter on a fresh page. Only the // document's first block has nothing above it to break from. if (ctx.bodyBlocks) attrs.breakBefore = 'page'; - if (ctx.masterPages[ctx.masterPages.length - 1] !== master) { - ctx.masterPages.push(master); - // The same paragraph carries the number the section restarts at, if it does. - const restart = Number(paraProps['style:page-number']); - ctx.masterPageStarts.push(Number.isFinite(restart) ? clampPageStart(restart) : null); - attrs.sectionBreak = true; - } + if (opensSection) attrs.sectionBreak = true; } if (kind === 'body') ctx.bodyBlocks++; // Tab stops live in a child element of the paragraph properties, so they come from @@ -1909,8 +1944,9 @@ function blockAttrs(paraProps: PropMap, textProps: PropMap, defaults: BlockDefau // default of 2 (on) — so only an explicit 0 disables it. if (paraProps['fo:widows'] === '0' || paraProps['fo:orphans'] === '0') attrs.widowControl = false; - // Paragraph background ("colored field") + per-side borders ("rule line"). - Object.assign(attrs, paraBoxAttrs(paraProps)); + // Paragraph background ("colored field") + per-side borders ("rule line"), less + // what the named style supplies. + for (const [k, v] of Object.entries(paraBoxAttrs(paraProps))) if (defaults.box[k] !== v) attrs[k] = v; return attrs; } @@ -2128,7 +2164,7 @@ function noteBodyStyle(e: Element, ctx: Ctx, kind: NoteKind): string | null { const named = ctx.resolver.namedAncestor(raw, 'paragraph'); if (!named || isStockNoteStyle(ctx.resolver, named)) return null; ctx.usedStyles.add(named); - return ctx.styleNames.get(named) ?? named.replace(/_20_/g, ' '); + return ctx.styleNames.get(named) ?? decodeStyleName(named); } // → the anchor node, with the note's own text collected into ctx for the @@ -2643,35 +2679,41 @@ function formatPt(v: number): string { // The heading elements of a whose leaves are all headings (each list-item // holds only a text:h and/or nested such lists) — ODF outline/chapter numbering, not a // real list, so they import as plain headings. null when it's a genuine list. -// The master page governing the most body blocks. Mirrors convertBlocks' body-level -// dispatch: only a paragraph or heading can name one, and '' is the file's own default. -function dominantMasterPage(elements: Element[], resolver: StyleResolver): string | null { +// The master governing the most body blocks, and the one the first paragraph starts on. +// Mirrors convertBlocks' body-level dispatch: only a paragraph or heading can name one, +// and '' is the file's own default. +function masterPagesOf(elements: Element[], resolver: StyleResolver): { dominant: string | null; leading: string | null } { const counts = new Map(); let current = ''; - const walk = (els: Element[]): void => { + // The master the first paragraph names; a paragraph naming none — or a leading block + // that is no paragraph at all, a table or a list — starts on Standard. + let leading: string | null = null; + const walk = (els: Element[], top = false): void => { for (const el of els) { - if (el.namespaceURI !== NS.text) continue; - if (el.localName === 'p' || el.localName === 'h') { - current = resolver.masterPageOf(el.getAttributeNS(NS.text, 'style-name')) ?? current; + if (el.namespaceURI === NS.text && (el.localName === 'p' || el.localName === 'h')) { + const own = resolver.masterPageOf(el.getAttributeNS(NS.text, 'style-name')); + leading ??= own ?? 'Standard'; + current = own ?? current; counts.set(current, (counts.get(current) ?? 0) + 1); - } else if (el.localName === 'list') { + } else if (el.namespaceURI === NS.text && el.localName === 'list') { const headings = outlineHeadingEls(el); if (headings) walk(headings); - } else if (el.localName === 'section') { + } else if (el.namespaceURI === NS.text && el.localName === 'section') { walk(Array.from(el.children)); - } else if (/-index$|^table-of-content$|^bibliography$/.test(el.localName)) { + } else if (el.namespaceURI === NS.text && /-index$|^table-of-content$|^bibliography$/.test(el.localName)) { const indexBody = el.getElementsByTagNameNS(NS.text, 'index-body')[0]; if (indexBody) walk(Array.from(indexBody.children)); } + if (top) leading ??= 'Standard'; } }; - walk(elements); + walk(elements, true); let dominant = ''; let governed = 0; for (const [name, count] of counts) { if (count > governed) { dominant = name; governed = count; } } - return dominant || null; + return { dominant: dominant || null, leading }; } function outlineHeadingEls(listEl: Element): Element[] | null { diff --git a/src/lib/import/styleResolver.ts b/src/lib/import/styleResolver.ts index ee657c6..85fc2a8 100644 --- a/src/lib/import/styleResolver.ts +++ b/src/lib/import/styleResolver.ts @@ -48,6 +48,7 @@ export type PropMap = Record; type StyleEntry = { parent: string | null; + next: string | null; // style:next-style-name text: PropMap; // style:text-properties para: PropMap; // style:paragraph-properties misc: PropMap; // table-column / table-row / table-cell properties @@ -134,6 +135,7 @@ function collectProps(el: Element, propsLocalName: string, into: PropMap): void function entryFromStyleElement(el: Element): StyleEntry { const entry: StyleEntry = { parent: el.getAttributeNS(NS.style, 'parent-style-name'), + next: el.getAttributeNS(NS.style, 'next-style-name'), text: {}, para: {}, misc: {}, @@ -197,12 +199,12 @@ export class StyleResolver { // Paragraph styles from — the file's named styles (as opposed to the // automatic styles, which are direct formatting). Own props only; the chain stays a chain. - namedParagraphStyles(): Map { - const out = new Map(); + namedParagraphStyles(): Map { + const out = new Map(); for (const name of this.namedParagraphNames) { const entry = this.styles.get(`paragraph\0${name}`); // display: only the file's own style:display-name — the caller decodes _20_ itself. - if (entry) out.set(name, { parent: entry.parent, display: this.displayNames.get(name), text: entry.text, para: entry.para }); + if (entry) out.set(name, { parent: entry.parent, next: entry.next, display: this.displayNames.get(name), text: entry.text, para: entry.para }); } return out; } @@ -626,6 +628,10 @@ export class StyleResolver { this.defaultMaster = name; } + hasMasterPage(name: string | null): name is string { + return !!name && !!this.masterPageEl(name); + } + // The master page a paragraph style switches to, walking style:parent-style-name. masterPageOf(styleName: string | null): string | null { const seen = new Set(); @@ -839,6 +845,13 @@ export class StyleResolver { return this.stylesDoc?.getElementsByTagNameNS(NS.text, 'outline-style')[0] ?? null; } + // Whether the bibliography numbers its entries — text:bibliography-configuration in + // office:styles, where both flags of a numbered bibliography live. + bibliographyNumbered(): boolean { + const cfg = this.stylesDoc?.getElementsByTagNameNS(NS.text, 'bibliography-configuration')[0]; + return cfg?.getAttributeNS(NS.text, 'numbered-entries') === 'true'; + } + // The page's own decoration (storage/pageDecor.ts): background and border ride the // page layout, the watermark is a fontwork shape in the master page's header — // exactly where LibreOffice keeps all three (probed). diff --git a/src/lib/styles/CLAUDE.md b/src/lib/styles/CLAUDE.md index 1dfd5bc..4956b16 100644 --- a/src/lib/styles/CLAUDE.md +++ b/src/lib/styles/CLAUDE.md @@ -91,7 +91,7 @@ holds it as a reactive singleton persisted to `edentext-styles` (same shape as ` relative sizes and repeated values don't land raw) over the built-ins, and `OdtImportResult.styles` hands them to `App.svelte`. Suppression no longer compares against constants: `blockDefaults` builds a per-block **yardstick** from the block's named style (size, margins, indent, font, bold, - color, italic/underline/strike), and only what exceeds it becomes direct formatting. Only + color, italic/underline/strike) plus its background and rule lines, and only what exceeds it becomes direct formatting. Only top-level blocks get a `styleName` — list items and cells reference producer plumbing styles. - **DOCX I/O works the same way.** *Export*: `buildDocx(…, styles)` emits the registry as docx-lib `paragraphStyles` (`id` via `docxStyleId` — "Heading 1" → `Heading1`, "Standard" → diff --git a/src/lib/styles/outlineNumbering.ts b/src/lib/styles/outlineNumbering.ts index 17d5b37..12a47f4 100644 --- a/src/lib/styles/outlineNumbering.ts +++ b/src/lib/styles/outlineNumbering.ts @@ -119,8 +119,10 @@ export function outlineCss( const deeper = levels .map((l, i) => (i + 1 > level && l.format !== 'none' ? `${COUNTER(i + 1)} ${Math.max(0, (l.start ?? 1) - 1)}` : '')) .filter(Boolean).join(' '); + // counter-set, not -reset: a reset on a later sibling opens a counter of its own that + // the headings after it do not increment (probed in Chromium), a set rewinds theirs. const block = [`counter-increment: ${COUNTER(level)}`]; - if (deeper) block.push(`counter-reset: ${deeper}`); + if (deeper) block.push(`counter-set: ${deeper}`); // The label hangs out of the paragraph's indent, so a title that wraps lines up // under itself — plus the section inset, which .tiptap's padding can't draw. const indent = own.indentCm ?? 0; diff --git a/src/styles/editor.css b/src/styles/editor.css index 9348873..6f13af5 100644 --- a/src/styles/editor.css +++ b/src/styles/editor.css @@ -565,6 +565,11 @@ .paper .tiptap .columns-node > :last-child { margin-bottom: 0; } +/* A heading stays with the text it introduces across a column break, as it does + across a page break (keep-with-next is implied for headings in both products). */ +.paper .tiptap .columns-node :is(h1, h2, h3, h4, h5, h6, h7, h8, h9, h10) { + break-after: avoid; +} /* A tab stop's leader fills the gap the tab was given as margin-left. The span is zero-width (tab-size:0), so its right padding edge is where the stop sits. Not scoped diff --git a/tests/roundtrip.test.ts b/tests/roundtrip.test.ts index 997686c..f15b3d3 100644 --- a/tests/roundtrip.test.ts +++ b/tests/roundtrip.test.ts @@ -1588,7 +1588,7 @@ describe('Leg 12: named paragraph styles (ODF)', () => { const sheet = builtinStyleSheet(); sheet.paragraph['Merksatz'] = { name: 'Merksatz', parent: 'Standard', next: 'Standard', - para: { indent: 2, spaceBefore: 6 }, text: { bold: true, color: '#0000AA' }, + para: { indent: 2, spaceBefore: 6, lineHeight: '1.15' }, text: { bold: true, color: '#0000AA' }, }; const doc: N = { @@ -1626,6 +1626,11 @@ describe('Leg 12: named paragraph styles (ODF)', () => { check('the style itself round-trips', imported?.parent === 'Standard', imported); check('with its own properties', imported?.text.bold === true && imported?.text.color === '#0000AA' && imported?.para.indent === 2 && imported?.para.spaceBefore === 6, imported); + // A style's proportional spacing is a bare factor; the file takes a percentage. + check('style line spacing round-trips', imported?.para.lineHeight === '1.15', imported?.para); + const docx = await buildDocx(doc, margins, 'portrait', undefined, null, 'A4', sheet); + const docxStyles = strFromU8(unzipSync(docx)['word/styles.xml']); + check('DOCX style carries the spacing', /w:styleId="Merksatz"[\s\S]*?w:line="276"/.test(docxStyles), docxStyles.slice(0, 200)); // A heading keeps rendering from the registry, not from copied attrs. check('heading needs no direct formatting', !blocks[0]?.content?.[0]?.marks, blocks[0]); }); @@ -1847,6 +1852,22 @@ describe('Leg 12: per-section page margins (ODT + DOCX)', () => { check('section margins round-trip', JSON.stringify(back) === JSON.stringify(wide), back); }); + // A short front matter on Standard before long chapters on their own masters: the + // geometry follows the chapters, the first section's blank header does not. + it('ODT: the first section keeps Standard\'s zones under a dominant later master', async () => { + const bookDoc: N = { type: 'doc', content: [ + P(null, T('title page')), + P({ sectionBreak: true }, T('chapter one')), + ...Array.from({ length: 5 }, (_, i) => P(null, T(`paragraph ${i}`))), + ] }; + const running = { type: 'doc', content: [P(null, T('running head'))] }; + const bytes = await buildOdt(bookDoc, margins, 'portrait', + { sections: [{ ...EMPTY_HF_SET }, { ...EMPTY_HF_SET, header: running }], pageCount: 2 }); + const res = importOdt(bytes); + check('first section has no header', res.header === null, res.header); + check('the chapter section keeps its header', JSON.stringify(res.hfSections?.[1]?.header).includes('running head'), res.hfSections?.[1]?.header); + }); + it('DOCX: each sectPr carries its own w:pgMar', async () => { const bytes = await buildDocx(secDoc, margins, 'portrait', { sections, pageCount: 2 }); const res = importDocx(bytes); diff --git a/tests/unit/outline-numbering.test.ts b/tests/unit/outline-numbering.test.ts index bd67cd9..b5555b8 100644 --- a/tests/unit/outline-numbering.test.ts +++ b/tests/unit/outline-numbering.test.ts @@ -98,6 +98,8 @@ describe('chapter numbering', () => { it('draws the label with counters that reset down the levels', () => { const css = outlineCss(outline); expect(css).toContain('counter-increment: edt-outline-1'); + // Set, not reset: a reset on a later chapter would open a counter its sections skip. + expect(css).toContain('counter-set: edt-outline-2 0 edt-outline-3 1'); expect(css).toMatch(/h2[^{\n]*::before \{\n {2}content: counter\(edt-outline-1, decimal\) "\." counter\(edt-outline-2, decimal\) " ";/); // A heading in a cell or a list item is not part of the chapter count. expect(css).toContain(':not(:is(td, th, li, .frame-node) *)'); diff --git a/tests/unit/section-page-numbering.test.ts b/tests/unit/section-page-numbering.test.ts index f13a507..90ee58a 100644 --- a/tests/unit/section-page-numbering.test.ts +++ b/tests/unit/section-page-numbering.test.ts @@ -43,9 +43,9 @@ describe('per-section page numbering', () => { const bytes = await buildDocx(doc, margins, 'portrait', hf as never); const xml = strFromU8(unzipSync(bytes)['word/document.xml']); expect(xml).toContain('/g)).toHaveLength(1); + // Every section begins a page, as naming a master page does in ODF; a continuous + // break is a columns group's, and both word processors ignore a start on one (probed). + expect(xml.match(//g)).toBeNull(); expect(starts(importDocx(bytes))).toEqual([null, null, 1]); }); });