diff --git a/docs/showcase/book-spread.png b/docs/showcase/book-spread.png
index 7524efe..ac6427f 100644
Binary files a/docs/showcase/book-spread.png and b/docs/showcase/book-spread.png differ
diff --git a/docs/showcase/book.docx b/docs/showcase/book.docx
index d832459..329d658 100644
Binary files a/docs/showcase/book.docx and b/docs/showcase/book.docx differ
diff --git a/docs/showcase/book.odt b/docs/showcase/book.odt
index ba6c34b..1241a55 100644
Binary files a/docs/showcase/book.odt and b/docs/showcase/book.odt differ
diff --git a/docs/showcase/book.png b/docs/showcase/book.png
index fd51dff..9f97c0a 100644
Binary files a/docs/showcase/book.png and b/docs/showcase/book.png differ
diff --git a/docs/showcase/newsletter.docx b/docs/showcase/newsletter.docx
index 1b92c94..aee01c1 100644
Binary files a/docs/showcase/newsletter.docx and b/docs/showcase/newsletter.docx differ
diff --git a/docs/showcase/newsletter.odt b/docs/showcase/newsletter.odt
index 47c012f..941da86 100644
Binary files a/docs/showcase/newsletter.odt and b/docs/showcase/newsletter.odt differ
diff --git a/docs/showcase/thesis-review.docx b/docs/showcase/thesis-review.docx
index 306c4fe..45084c1 100644
Binary files a/docs/showcase/thesis-review.docx and b/docs/showcase/thesis-review.docx differ
diff --git a/docs/showcase/thesis-review.odt b/docs/showcase/thesis-review.odt
index d88968e..871c2a5 100644
Binary files a/docs/showcase/thesis-review.odt and b/docs/showcase/thesis-review.odt differ
diff --git a/docs/showcase/thesis.docx b/docs/showcase/thesis.docx
index 23c6efb..9cecce7 100644
Binary files a/docs/showcase/thesis.docx and b/docs/showcase/thesis.docx differ
diff --git a/docs/showcase/thesis.odt b/docs/showcase/thesis.odt
index d5f2e73..ff9fa4f 100644
Binary files a/docs/showcase/thesis.odt and b/docs/showcase/thesis.odt differ
diff --git a/src/lib/export/CLAUDE.md b/src/lib/export/CLAUDE.md
index e6f0e20..5d398a3 100644
--- a/src/lib/export/CLAUDE.md
+++ b/src/lib/export/CLAUDE.md
@@ -68,6 +68,10 @@ fallback download shows a one-time hint (`edentext-download-hint`) that the brow
- **`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. `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).
+- **Every master of a section writes the running zone, blank where its own page has none.**
+ The page layout takes the band off the page margin for the whole section (`layoutFor`), so
+ a master without the zone puts its body up into the band — the page a section opens on and
+ the pages after it are separate masters, and it cost a chapter opening 0.8cm of its margin.
- **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
diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts
index a6b95ab..b580dcb 100644
--- a/src/lib/export/odt.ts
+++ b/src/lib/export/odt.ts
@@ -3071,7 +3071,9 @@ function injectIntoHeaderZones(styles: string, shapes: string): string {
const blank = `${shapes}`;
const out = styles.replace(//g, '');
// Every master page, the section masters included: the decor shows on their pages too.
- return out.replace(/]*(?:\/>|>[\s\S]*?<\/style:master-page>)/g, (master) => {
+ // Self-closing first, or a greedy attribute run eats the `/` and the open form swallows
+ // an empty master page together with the next one's closing tag.
+ return out.replace(/]*\/>|]*>[\s\S]*?<\/style:master-page>/g, (master) => {
if (!/]/.test(master)) {
return master.endsWith('/>')
? master.replace(/\/>$/, `>${blank}`)
@@ -5496,24 +5498,31 @@ function masterPageXml(name: string, layoutName: string, set: HfSet, pageCount:
return hfVariantZoneXml(kind, suffix, para as TiptapNode, pageCount, mint, pfx);
};
const on = (flag: boolean, running: HfDoc, variant: HfDoc) => flag && (!hfIsEmpty(running) || !hfIsEmpty(variant));
+ // The layout takes the band off the page margin for a section that has a zone of this
+ // kind at all (layoutFor), so every master of it writes the running zone — blank where
+ // its own page has none — or the body of that page sits in the reserved band.
+ const has = {
+ header: !!(set.header || set.headerFirst || set.headerEven),
+ footer: !!(set.footer || set.footerFirst || set.footerEven),
+ };
let body: string;
if (part === 'right' || part === 'left') {
// The one page this master governs shows the first-page variant, else the running
// zone — the left one on a left page.
const one = (running: HfDoc, first: HfDoc, left: HfDoc): HfDoc =>
set.differentFirstPage ? first : part === 'left' && set.differentOddEven ? left : running;
- body = zone('header', null, one(set.header, set.headerFirst, set.headerEven))
- + zone('footer', null, one(set.footer, set.footerFirst, set.footerEven));
+ body = zone('header', null, one(set.header, set.headerFirst, set.headerEven), has.header)
+ + zone('footer', null, one(set.footer, set.footerFirst, set.footerEven), has.footer);
} else {
const hFirst = part === 'all' && on(set.differentFirstPage, set.header, set.headerFirst);
const hEven = on(set.differentOddEven, set.header, set.headerEven);
const fFirst = part === 'all' && 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.
- body = zone('header', null, set.header, hFirst || hEven)
+ body = zone('header', null, set.header, has.header)
+ (hEven ? zone('header', 'left', set.headerEven, true) : '')
+ (hFirst ? zone('header', 'first', set.headerFirst, true) : '')
- + zone('footer', null, set.footer, fFirst || fEven)
+ + zone('footer', null, set.footer, has.footer)
+ (fEven ? zone('footer', 'left', set.footerEven, true) : '')
+ (fFirst ? zone('footer', 'first', set.footerFirst, true) : '');
}
diff --git a/src/lib/import/CLAUDE.md b/src/lib/import/CLAUDE.md
index 346c100..dab903f 100644
--- a/src/lib/import/CLAUDE.md
+++ b/src/lib/import/CLAUDE.md
@@ -25,6 +25,7 @@ The **Open .odt** button (`App.svelte`) parses an uploaded file with `importOdt(
- **`style:contextual-spacing`** drops a paragraph's own spacing towards a neighbour of the same style (LibreOffice's `List` styles carry it; Word's `w:contextualSpacing` is the same rule). The editor has no such mode, so the suppressed side becomes an explicit 0 — and in a list, where each item wraps its own paragraph, the neighbour is the adjacent *item's*.
- **Default suppression** keeps round trips (editor → LibreOffice → editor) from accreting explicit attrs: values matching the editor's defaults (12pt body, `HEADING_STYLE_OVERRIDES` sizes/margins, **0 paragraph spacing**, list margin 0, Times New Roman/Liberation Serif, `#000000` text) are imported as `null`/no mark, with small tolerances for producer unit rounding (pt↔cm↔twips). This is why the editor's defaults must equal Word's/LibreOffice's (below) — an editor-only default is indistinguishable from a failed style resolution and silently lands in every imported document.
- **A heading is bold by default only where the bold built-in still renders it.** `blockDefaults` suppresses a run mark the block's style supplies, and the editor's `Heading` parent supplies bold — but a file that declares its own heading style re-parents it to `Standard` (`collectStyleSheet`), so nothing supplies bold there and an explicit `w:b` / `fo:font-weight` has to stay a mark or the heading renders regular. Gated on the style's own `w:basedOn` / `style:parent-style-name` (`styleBasedOn`, `styleParent`), which is exactly what decides whether the built-in's parent survives.
+- **A run's underline is suppressed only where it draws the *same* line.** `lineSig` compares the run's shape and colour against the style's, so a red or wavy underline under a plainly underlining style survives — only a bare one repeating its style is dropped, which is what neither format can tell from a failed resolution. Both importers, `marksFor`; a strike compares its `double` the same way.
- **Mapping:** `text:h` → heading (level clamped to `MAX_HEADING_LEVEL`), per-depth `text:list-level-style-*` defs decide bullet vs ordered and `listStyleType` (reverse lookup `orderedTypeFromFormat`) — unless the list style is a **named** one (`StyleResolver.isNamedListStyle`): then the outermost list gets `listStyleName` (display name decoded), no per-level attrs are derived, and `collectStyleSheet`/`listStyleFromOdf` put the definition into `sheet.list`, tables handle covered cells / `number-columns-repeated` / header rows / colspans / cell shading (`StyleResolver.cellBackgroundColor` → `backgroundColor`) / cell borders (`StyleResolver.cellBorders` + `borderAttrFromOdf` → the per-side border attrs) / table margins (`tableMargins` reads the table style's `fo:margin-*`, `style:width`/`style:rel-width` and `table:align` against `Ctx.contentWidthCm` — so a table narrower than the text width stays narrow instead of being stretched; DOCX does the same from `w:tblInd` + the `w:tblGrid`/`w:tblW` width), `text:s`/`text:tab`/`text:line-break` become spaces/`\t`/`hardBreak`, `draw:frame`/`draw:image` → an `image` node (`convertFrame`: bytes read from the zip's `Pictures/` entry into a data-URI, `svg:width`/`-height` cm → px, `svg:title` → `alt`, `draw:transform` rotate → `rotation`; a non-`as-char` anchor or a graphic style's `style:wrap` → floating `wrap` mode via `StyleResolver.graphicProps` — an **explicit `as-char` anchor is always inline**: LibreOffice's named Graphics style carries a `style:wrap` that would otherwise float every inherited frame), text fields keep their stored value. **Text boxes / shapes** (`convertDrawElement`): `draw:frame`+`draw:text-box` → a `textBox` node (`convertTextBoxFrame`: height = `fo:min-height` else `svg:height`; fill/stroke read straight from `graphicProps`' `draw:fill(-color)`/`draw:stroke`/`svg:stroke-*`, values equal to the editor defaults suppressed, none → explicit `null`); `draw:rect`/`draw:ellipse`/`draw:custom-shape` with a rect/round-rectangle/ellipse preset → a `textBox` with the matching `shapeKind` (`convertShape`), and `draw:polygon`/`polyline`/`path`/`connector` — or a preset no gallery covers but whose `draw:enhanced-path` is a plain outline — → a box carrying that outline as `shapePath` (`convertFreeform`, `docs/architecture/frames.md`; a geometry built from modifier formulas still reads as "Unsupported shapes were removed"). A box is an inline node, so it stays exactly where the frame sits — in the paragraph's own content, beside the runs around it, cells and list items included; only a box **inside another box** is unwrapped in place, with a warning. The DOCX importer mirrors all of this — its drawing root is the `w:drawing`'s **direct** `wp:inline`/`wp:anchor` child, since a box holding a picture nests a second drawing and a subtree search reads *its* root instead, dropping the box and the caption in it (`convertWpsShape` for DrawingML `wps:wsp` — probed before the image `a:blip` path, `convertPict` for legacy VML; Word's `mc:AlternateContent` uses only the `mc:Choice` branch so the VML fallback can't double-import).
- **Image formats (`import/imageFormats.ts`, shared by both importers):** `
` only renders a fixed set of formats, so `imageDataUrl(bytes, path)` resolves the mime by extension **then** magic-number (mislabelled/extensionless entries still work) and returns `null` for anything unrenderable — the importer then skips it with a specific "format the browser can't display" warning instead of a broken `
`. A frame's multiple `` alternatives (ODF) and a Word `a:blip`'s `svgBlip` alternative are probed for the first *displayable* one, and ``-wrapped frames are unwrapped (the hyperlink dropped). **Non-web formats we can decode client-side** (currently **TIFF**) are converted to PNG up front by `convertUnsupportedImages(bytes)` — an async pass `applyImport` runs before the synchronous import, passing a `path → PNG data-URI` map (`ConvertedImages`) into `importOdt`/`importDocx` (default empty, so tests stay sync). The decoder (`utif2`) is a **dynamic `import()`** → its own lazy chunk, loaded only when a convertible image is actually present, so ordinary docs pay nothing. **EMF** goes through the same lazy pre-pass into an SVG picture (`import/emf.ts`, below). **SVM/WMF** still have no decoder (SVM is StarOffice-proprietary, WMF a different 16-bit record format) → skipped with the format warning; LibreOffice/WASM conversion was ruled out (no backend; WASM LO is ~300MB). A **CMYK** JPEG loses its ICC profile (`stripCmykIccProfile`): Chromium colour-manages through it, LibreOffice and Word convert naively, and the file's own colours are the ones every word processor shows (measured on the contract fixture's logo: blue (0,80,131) → (0,77,183) against LibreOffice's (0,32,183), black (57,53,54) → (11,11,18) against (0,0,0)).
- **EMF (`import/emf.ts`)**: `emfToSvg(bytes)` replays the record stream into SVG — window/viewport plus world transform mapped per point, a DC stack (`SAVEDC`/`RESTOREDC`), pens/brushes/fonts (incl. the stock objects), the 16- and 32-bit poly records, path brackets, `RECTANGLE`/`ELLIPSE`, `EXTTEXTOUT` (its per-character advances become `textLength`, so labels keep their width) and `STRETCHDIBITS` (the DIB re-wrapped as a .bmp data-URI). **Text is stored as raw code units**, so a symbol font leaves lone surrogates behind — they must be stripped or `encodeURIComponent` throws and the whole picture is lost. Unknown records are skipped; a file that yields nothing returns null and keeps the placeholder.
diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts
index 59035ec..76f2cec 100644
--- a/src/lib/import/docx.ts
+++ b/src/lib/import/docx.ts
@@ -792,8 +792,9 @@ type BlockDefaults = {
fonts: Set;
color: string;
italic: boolean;
- underline: boolean;
- strike: boolean;
+ // The line the style draws (lineSig), null where it draws none.
+ underline: string | null;
+ strike: string | null;
caps: CapsMode | null;
};
@@ -821,8 +822,8 @@ function blockDefaults(baseRun: RunProps, headingLevel: number | null, boldByDef
fonts,
color: hexColor(baseRun.color) ?? '#000000',
italic: baseRun.italic ?? (headingLevel != null && HEADING_ITALIC[headingLevel - 1]),
- underline: !!baseRun.underline,
- strike: !!baseRun.strike,
+ underline: lineSig(baseRun).underline,
+ strike: lineSig(baseRun).strike,
caps: baseRun.caps || null,
};
}
@@ -1914,11 +1915,12 @@ function marksFor(props: RunProps, defaults: BlockDefaults, inLink: boolean): Ma
// Marks the block's named style already renders need no mark of their own.
if (props.italic && !defaults.italic) marks.push({ type: 'italic' });
- if (props.underline && !inLink && !defaults.underline) { // link underline is the CSS default
- const line = underlineAttrs(props);
- marks.push(line ? { type: 'underline', attrs: line } : { type: 'underline' });
+ const line = lineSig(props);
+ if (props.underline && !inLink && line.underline !== defaults.underline) { // link underline is the CSS default
+ const attrs = underlineAttrs(props);
+ marks.push(attrs ? { type: 'underline', attrs } : { type: 'underline' });
}
- if (props.strike && !defaults.strike) {
+ if (props.strike && line.strike !== defaults.strike) {
marks.push({ type: 'strike', ...(props.doubleStrike ? { attrs: { lineStyle: 'double' } } : {}) });
}
if (props.vertAlign === 'superscript') marks.push({ type: 'superscript' });
@@ -1961,6 +1963,15 @@ function underlineAttrs(props: RunProps): Record | undefined {
return Object.keys(attrs).length ? attrs : undefined;
}
+// The lines a style draws, in the shape marksFor gives a run: a run drawing exactly that
+// adds nothing, a different shape or colour is direct formatting the style can't carry.
+function lineSig(props: RunProps): { underline: string | null; strike: string | null } {
+ return {
+ underline: props.underline ? JSON.stringify(underlineAttrs(props) ?? {}) : null,
+ strike: props.strike ? (props.doubleStrike ? 'double' : '') : null,
+ };
+}
+
// ---- images -----------------------------------------------------------------
function loadImageDataUrl(path: string, ctx: Ctx): string | null {
const cached = ctx.imageCache.get(path);
diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts
index 46e0192..0698238 100644
--- a/src/lib/import/odt.ts
+++ b/src/lib/import/odt.ts
@@ -1430,8 +1430,9 @@ type BlockDefaults = {
// (only bold has fontWeight:'normal'), so it keeps the style's rendering.
// ponytail: per-mark "off" overrides need mark attrs like fontWeight's; add if files need it.
italic: boolean;
- underline: boolean;
- strike: boolean;
+ // The line the style draws (lineSig), null where it draws none.
+ underline: string | null;
+ strike: string | null;
caps: CapsMode | null;
// The style's own paragraph background and rule lines (paraBoxAttrs).
box: Record;
@@ -1477,8 +1478,8 @@ function blockDefaults(resolver: StyleResolver, named: string | null, headingLev
fonts: new Set(headingLevel != null ? DEFAULT_HEADING_FONTS : DEFAULT_FONTS),
color: '#000000',
italic: hdef ? hdef.italic : false,
- underline: false,
- strike: false,
+ underline: null,
+ strike: null,
caps: null,
box: {},
};
@@ -1506,8 +1507,8 @@ function blockDefaults(resolver: StyleResolver, named: string | null, headingLev
fonts,
color: (text['fo:color'] && normalizeColor(text['fo:color'])) || fallback.color,
italic: fontStyle === 'italic' || fontStyle === 'oblique',
- 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',
+ underline: lineSig(text, 'underline'),
+ strike: lineSig(text, 'line-through'),
caps: capsFromOdf(text),
box: paraBoxAttrs(para),
};
@@ -1778,8 +1779,8 @@ function charDefaults(ctx: Ctx, base: BlockDefaults, odfName: string): BlockDefa
fonts,
color: (props['fo:color'] && normalizeColor(props['fo:color'])) || base.color,
italic: fontStyle ? fontStyle === 'italic' || fontStyle === 'oblique' : base.italic,
- underline: props['style:text-underline-style'] ? props['style:text-underline-style'] !== 'none' : base.underline,
- strike: props['style:text-line-through-style'] ? props['style:text-line-through-style'] !== 'none' : base.strike,
+ underline: props['style:text-underline-style'] ? lineSig(props, 'underline') : base.underline,
+ strike: props['style:text-line-through-style'] ? lineSig(props, 'line-through') : base.strike,
caps: capsFromOdf(props) ?? base.caps,
};
}
@@ -2589,6 +2590,15 @@ function lineAttrs(style: string, type: string | undefined, color: string | unde
return Object.keys(attrs).length ? attrs : undefined;
}
+// The line a style draws, in the shape marksFor gives a run: a run drawing exactly that
+// adds nothing, a different shape or colour is direct formatting the style can't carry.
+function lineSig(props: PropMap, kind: 'underline' | 'line-through'): string | null {
+ const style = props[`style:text-${kind}-style`];
+ if (!style || style === 'none') return null;
+ const color = kind === 'underline' ? props['style:text-underline-color'] : undefined;
+ return JSON.stringify(lineAttrs(style, props[`style:text-${kind}-type`], color) ?? {});
+}
+
function capsFromOdf(props: PropMap): CapsMode | null {
if (props['fo:font-variant'] === 'small-caps') return 'smallCaps';
const t = props['fo:text-transform'];
@@ -2641,12 +2651,12 @@ function marksFor(props: PropMap, resolver: StyleResolver, defaults: BlockDefaul
const fs = props['fo:font-style'];
if ((fs === 'italic' || fs === 'oblique') && !defaults.italic) marks.push({ type: 'italic' });
const ul = props['style:text-underline-style'];
- if (ul && ul !== 'none' && !defaults.underline) {
+ if (ul && ul !== 'none' && lineSig(props, 'underline') !== defaults.underline) {
const line = lineAttrs(ul, props['style:text-underline-type'], props['style:text-underline-color']);
marks.push(line ? { type: 'underline', attrs: line } : { type: 'underline' });
}
const lt = props['style:text-line-through-style'];
- if (lt && lt !== 'none' && !defaults.strike) {
+ if (lt && lt !== 'none' && lineSig(props, 'line-through') !== defaults.strike) {
const line = lineAttrs(lt, props['style:text-line-through-type'], undefined);
marks.push(line ? { type: 'strike', attrs: line } : { type: 'strike' });
}