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}${tag}>`;
+ 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}${tag}>`;
+ 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]);
});
});