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.
This commit is contained in:
1 parent
406206c300
commit
7b43ee71ad
14 files changed
+395
-173
No files matched your search
@@ -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
|
||||
|
||||
+81
-28
@@ -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:numbering>', `<w:num w:numId="${numId}"><w:abstractNumId w:val="${absId}"/></w:num></w:numbering>`);
|
||||
}
|
||||
// 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 = `<w:numPr><w:ilvl w:val="${n - 1}"/><w:numId w:val="${numId}"/></w:numPr>`;
|
||||
stylesXml = stylesXml.replace(
|
||||
new RegExp(`(<w:style w:type="paragraph"[^>]*w:styleId="Heading${n}"[\\s\\S]*?)(<w:pPr>)`),
|
||||
`$1$2${numPr}`);
|
||||
new RegExp(`(<w:style w:type="paragraph"[^>]*w:styleId="Heading${n}"[\\s\\S]*?<w:pPr>(?:<w:(?:pStyle|keepNext|keepLines|pageBreakBefore|framePr|widowControl)\\b[^>]*/>)*)`),
|
||||
`$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('<w:pBdr>')) continue;
|
||||
const sorted = xml.replace(/<w:pBdr>([\s\S]*?)<\/w:pBdr>/g, (_m, inner: string) => {
|
||||
const sides = inner.match(/<w:(?:top|left|bottom|right|between|bar)\b[^>]*\/>/g) ?? [];
|
||||
if (sides.join('') !== inner) return _m;
|
||||
const rank = (s: string) => PBDR_ORDER.indexOf(/^<(w:\w+)/.exec(s)![1]);
|
||||
return `<w:pBdr>${[...sides].sort((a, b) => rank(a) - rank(b)).join('')}</w:pBdr>`;
|
||||
});
|
||||
if (sorted === xml) continue;
|
||||
files[path] = strToU8(sorted);
|
||||
changed = true;
|
||||
}
|
||||
if (!changed) return bytes;
|
||||
const out: Record<string, [Uint8Array, { level: 6 }]> = {};
|
||||
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<string, number> = {};
|
||||
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<string, unknown> = {};
|
||||
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<HfSet> = 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))));
|
||||
}
|
||||
+139
-78
@@ -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 <text:p> 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, '>').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<st
|
||||
|
||||
// A style's OWN properties as ODF attributes — the chain stays a chain in the file,
|
||||
// so only what the style itself declares is written.
|
||||
function ownStyleAttrs(style: { para: Record<string, unknown>; text: Record<string, unknown> }) {
|
||||
function ownStyleAttrs(style: { para: Record<string, unknown>; text: Record<string, unknown> }, inherited: ParaProps = {}) {
|
||||
const p = style.para as Record<string, string | number | undefined>;
|
||||
const t = style.text as Record<string, string | number | boolean | undefined>;
|
||||
const para: Record<string, string> = {};
|
||||
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<string>,
|
||||
const odfName = odfStyleName(name);
|
||||
if (findAutoStyle(styles, odfName)) continue;
|
||||
added.push(setTagAttrs('<style:style/>', {
|
||||
'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<string>,
|
||||
const odfName = odfStyleName(style.name);
|
||||
if (findAutoStyle(styles, odfName)) continue;
|
||||
const attrs: Record<string, string> = {
|
||||
'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('<style:style/>', attrs).replace('/>', '>')}</style:style>`, '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<string>,
|
||||
continue;
|
||||
}
|
||||
const attrs: Record<string, string> = {
|
||||
'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 (!/<style:header[\s/>]/.test(styles)) {
|
||||
const zone = `<style:header><text:p text:style-name="Header">${shapes}</text:p></style:header>`;
|
||||
const out = styles.replace(/<style:header-style\s*\/>/, '<style:header-style><style:header-footer-properties fo:min-height="0cm" fo:margin-bottom="0cm"/></style:header-style>');
|
||||
return /<style:master-page\b[^>]*\/>/.test(out)
|
||||
? out.replace(/(<style:master-page\b[^>]*)\/>/, `$1>${zone}</style:master-page>`)
|
||||
: out.replace(/(<style:master-page\b[^>]*>)/, `$1${zone}`);
|
||||
}
|
||||
return styles.replace(/<style:header(-first|-left)?\s*\/>|<style:header(?:-first|-left)?>(\s*<text:p[^>]*>)?/g, (m) => {
|
||||
if (m.endsWith('/>')) {
|
||||
const tag = /style:header(?:-first|-left)?/.exec(m)![0];
|
||||
return `<${tag}><text:p text:style-name="Header">${shapes}</text:p></${tag}>`;
|
||||
const blank = `<style:header><text:p text:style-name="Header">${shapes}</text:p></style:header>`;
|
||||
const out = styles.replace(/<style:header-style\s*\/>/g, '<style:header-style><style:header-footer-properties fo:min-height="0cm" fo:margin-bottom="0cm"/></style:header-style>');
|
||||
// Every master page, the section masters included: the decor shows on their pages too.
|
||||
return out.replace(/<style:master-page\b[^>]*(?:\/>|>[\s\S]*?<\/style:master-page>)/g, (master) => {
|
||||
if (!/<style:header[\s/>]/.test(master)) {
|
||||
return master.endsWith('/>')
|
||||
? master.replace(/\/>$/, `>${blank}</style:master-page>`)
|
||||
: master.replace(/(<style:master-page\b[^>]*>)/, `$1${blank}`);
|
||||
}
|
||||
return m.includes('<text:p') ? `${m}${shapes}` : `${m}<text:p text:style-name="Header">${shapes}</text:p>`;
|
||||
return master.replace(/<style:header(-first|-left)?\s*\/>|<style:header(?:-first|-left)?>(\s*<text:p[^>]*>)?/g, (m) => {
|
||||
const tag = /style:header(?:-first|-left)?/.exec(m)![0];
|
||||
if (/^<style:header(?:-first|-left)?\s*\/>$/.test(m)) return `<${tag}><text:p text:style-name="Header">${shapes}</text:p></${tag}>`;
|
||||
if (!m.includes('<text:p')) return `${m}<text:p text:style-name="Header">${shapes}</text:p>`;
|
||||
// A blank zone's paragraph is self-closing; the shape needs it open.
|
||||
return m.endsWith('/>') ? `${m.slice(0, -2)}>${shapes}</text:p>` : `${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('<text:bibliography-configuration')) return odtBytes;
|
||||
files['styles.xml'] = strToU8(styles.replace('</office:styles>',
|
||||
'<text:bibliography-configuration text:numbered-entries="true" text:sort-by-position="true"/></office:styles>'));
|
||||
return rezipOdt(files);
|
||||
}
|
||||
|
||||
function rewriteStylesXml(odtBytes: Uint8Array, lang: { language: string; country: string } | null, pageFormat: PageFormat, orientation: Orientation, sheet: StyleSheet, used: Set<string>, usedTables: Set<string> = new Set(), usedLists: Set<string> = 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(/<style:page-layout-properties [^>]*/, (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 {
|
||||
`</text:${spec.el}-entry-template>`;
|
||||
// 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 = /<office:text\b[^>]*>/.exec(content);
|
||||
if (!body) return odtBytes;
|
||||
const first = /<text:(p|h)\b([^>]*)>/.exec(content.slice(body.index + body[0].length));
|
||||
// Past the tracked-changes registry, whose paragraphs are deleted text.
|
||||
const registryEnd = content.indexOf('</text:tracked-changes>', body.index);
|
||||
const from = registryEnd >= 0 ? registryEnd + '</text:tracked-changes>'.length : body.index + body[0].length;
|
||||
const first = /<text:(p|h)\b([^>]*)>/.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(`<style:${kind}-style>[\\s\\S]*?</style:${kind}-style>`),
|
||||
`<style:${kind}-style><style:header-footer-properties fo:min-height="${minH}cm" ${spacingAttr}="0cm" style:dynamic-spacing="false"/></style:${kind}-style>`,
|
||||
);
|
||||
// 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(`<style:style style:name="${name}" style:family="paragraph" style:parent-style-name="${parent}">${props}</style:style>`);
|
||||
styles = styles.replace(
|
||||
new RegExp(`(<style:style style:name="${styleName}"[^>]*?)/>`),
|
||||
`$1>${props}</style:style>`,
|
||||
new RegExp(`(<style:${kind}>\\s*<text:p )text:style-name="${parent}"`),
|
||||
`$1text:style-name="${name}"`,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -5384,26 +5422,38 @@ function hfVariantZoneXml(kind: 'header' | 'footer', suffix: 'first' | 'left' |
|
||||
// A section's own master page: the Standard one cloned under its own name, carrying that
|
||||
// section's zones. ODF has no per-section header/footer other than this.
|
||||
function masterPageXml(name: string, layoutName: string, set: HfSet, pageCount: number, mint: (styleXml: string) => 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 `<style:master-page style:name="${name}" style:page-layout-name="${layoutName}">${body}</style:master-page>`;
|
||||
}
|
||||
|
||||
// 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:master-page\b[^>]*style:page-layout-name="([^"]*)"/.exec(styles)?.[1] ?? 'pm1';
|
||||
const layoutXml = new RegExp(`<style:page-layout\\b[^>]*style:name="${layout}"[\\s\\S]*?</style:page-layout>`).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
|
||||
? `<style:${kind}-style/>`
|
||||
: `<style:${kind}-style><style:header-footer-properties fo:min-height="${round3(minH)}cm" ${kind === 'header' ? 'fo:margin-bottom' : 'fo:margin-top'}="0cm" style:dynamic-spacing="false"/></style:${kind}-style>`);
|
||||
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(/<style:page-layout-properties\b[^>]*>/, (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('<style:page-layout-properties ', `<style:page-layout-properties style:num-format="${numFormat}" `);
|
||||
}
|
||||
return p;
|
||||
}));
|
||||
})
|
||||
.replace(/<style:header-style\b[^>]*?(?:\/>|>[\s\S]*?<\/style:header-style>)/, '')
|
||||
.replace(/<style:footer-style\b[^>]*?(?:\/>|>[\s\S]*?<\/style:footer-style>)/, '');
|
||||
xml = xml.replace('</style:page-layout>', `${bandXml('header', top.minH)}${bandXml('footer', bottom.minH)}</style:page-layout>`);
|
||||
layouts.push(xml);
|
||||
return name;
|
||||
};
|
||||
const hfStyles: string[] = [];
|
||||
|
||||
@@ -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 `<draw:frame>` whose `<draw:object>` resolves to a sub-document with a `math:math` root (or holds one inline) → a `formula` node. Our own `<annotation encoding="application/x-tex">` 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.
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -169,6 +169,7 @@ export class DocxStyles {
|
||||
private defaultsRun: RunProps = {};
|
||||
private ownRun = new Map<string, RunProps>(); // styleId → own w:rPr
|
||||
private basedOn = new Map<string, string | null>();
|
||||
private next = new Map<string, string | null>(); // w:next
|
||||
private memoOwn = new Map<string, RunProps>();
|
||||
private styleNum = new Map<string, { numId: number; ilvl: number }>();
|
||||
private ownOutline = new Map<string, number>(); // 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<string, { name: string; basedOn: string | null }> {
|
||||
const out = new Map<string, { name: string; basedOn: string | null }>();
|
||||
for (const [id, name] of this.paraStyleNames) out.set(id, { name, basedOn: this.basedOn.get(id) ?? null });
|
||||
namedParagraphStyles(): Map<string, { name: string; basedOn: string | null; next: string | null }> {
|
||||
const out = new Map<string, { name: string; basedOn: string | null; next: string | null }>();
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
+92
-50
@@ -143,8 +143,10 @@ type Ctx = {
|
||||
files: Record<string, Uint8Array>;
|
||||
imageCache: Map<string, string>;
|
||||
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<string, string>();
|
||||
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<string, string>[]): Record<string, string> {
|
||||
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<string, string>;
|
||||
};
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// <text:note> → 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 <text:list> 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<string, number>();
|
||||
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 {
|
||||
|
||||
@@ -48,6 +48,7 @@ export type PropMap = Record<string, string>;
|
||||
|
||||
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 <office:styles> — 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<string, { parent: string | null; display?: string; text: PropMap; para: PropMap }> {
|
||||
const out = new Map<string, { parent: string | null; display?: string; text: PropMap; para: PropMap }>();
|
||||
namedParagraphStyles(): Map<string, { parent: string | null; next: string | null; display?: string; text: PropMap; para: PropMap }> {
|
||||
const out = new Map<string, { parent: string | null; next: string | null; display?: string; text: PropMap; para: PropMap }>();
|
||||
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<string>();
|
||||
@@ -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).
|
||||
|
||||
@@ -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" →
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
+22
-1
@@ -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);
|
||||
|
||||
@@ -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) *)');
|
||||
|
||||
@@ -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('<w:pgNumType w:start="1"');
|
||||
// A continuous section is one both word processors ignore the start on (probed),
|
||||
// so the restarting section is the one break that is not continuous.
|
||||
expect(xml.match(/<w:type w:val="continuous"\/>/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(/<w:type w:val="continuous"\/>/g)).toBeNull();
|
||||
expect(starts(importDocx(bytes))).toEqual([null, null, 1]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user