fix: a heading keeps the bold its own run declares
Both importers drop a mark the block's style supplies, and a heading's bold was taken as given. The registry style that renders it is the file's, though — re-parented to Standard wherever the file declares a basedOn / parent-style-name — so nothing supplied what the mark had been deleted for, and a 23pt title line came out 149.7mm against LibreOffice's 161.7. Gated on that parent now; a LibreOffice file, which writes fo:font-weight on every Heading_20_N, is untouched. Parity corpus 1892 → 1889.
This commit is contained in:
1 parent
eb3a9f95c1
commit
8a9b391d76
7 files changed
+71
-5
No files matched your search
@@ -167,6 +167,7 @@ The gap against Word/LibreOffice, most valuable first. Reviewed 2026-08-15.
|
||||
**Content an imported document loses**
|
||||
- Charts are **drawn** from the file (`import/chart.ts`: DrawingML `chartN.xml` and ODF `chart:chart`), but as a picture, not a chart object — a re-export carries the drawing and the numbers behind it are no longer editable. The same holds for an **EMF** metafile (`import/emf.ts`): it is drawn, but as the SVG picture it was rebuilt into, and only from the record set a plot consists of — a hatched brush, a clipping region or a rotated bitmap is skipped. **WMF/SVM** metafiles and OLE objects still keep their box and a placeholder label, and export writes that back out: WMF is a different (16-bit) record format, SVM is StarOffice-proprietary, and an OLE object cannot be rendered without its application
|
||||
- Two drawings still drop with the "Drawings were removed" warning: a **text box in the header/footer** (the zone is a one-paragraph document, so no block node fits in it — whatever the box carried, a letterhead logo included, goes with it) and a **shape group** (ODF `draw:g`: unwrapping it would anchor every child on its own, and a box cannot be put at a free point — see the limitation below)
|
||||
- A table of contents is **regenerated** on load, never read from the field's cached rows — so an index a producer built with other settings comes back with every heading in the document, dot leaders and live page numbers. The one switch worth reading is Word's `TOC \n` / an ODF entry template with no `<text:index-entry-page-number/>`: an index that deliberately shows no page numbers is a layout, not a stale cache
|
||||
- A drawing tool: a freeform, a polygon or a connector **imports, draws and saves** (see below), but there is no way to author one here. A Word connector preset (`bentConnector3`) is also still dropped — Word resolves that geometry and writes no path for it
|
||||
|
||||
**Missing while writing**
|
||||
|
||||
@@ -20,6 +20,7 @@ The **Open .odt** button (`App.svelte`) parses an uploaded file with `importOdt(
|
||||
- **A run is measured against the block's own size**, not the yardstick's: a list item whose style sets 11pt would otherwise drop every 12pt run as "the style supplies it" and render it at 11 (`convertParaLike`, `runDefaults`).
|
||||
- **The yardstick is the style the block can name.** Only a **body** block carries a `styleName`, so a paragraph in a cell or a text box is measured against the *default* paragraph style instead of its own — otherwise everything its style provides is suppressed as "the style will supply it" and nothing ever does (a Word caption in a text box arrived without its italic or its colour).
|
||||
- **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.
|
||||
- **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`), 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 a block node but its frame sits inside a `text:p`, so converted boxes ride `Ctx.pendingBlocks` and `convertBlocks` flushes them after the anchor paragraph (dropping our own export's empty anchor); in cells/other boxes their blocks are 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):** `<img>` 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 `<img>`. A frame's multiple `<draw:image>` alternatives (ODF) and a Word `a:blip`'s `svgBlip` alternative are probed for the first *displayable* one, and `<draw:a>`-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.
|
||||
|
||||
@@ -753,8 +753,9 @@ const FONT_TWINS: Record<string, string[]> = {
|
||||
};
|
||||
|
||||
// baseRun = docDefaults ← the paragraph style's basedOn chain, i.e. exactly what the
|
||||
// style gives its runs.
|
||||
function blockDefaults(baseRun: RunProps, headingLevel: number | null, boldByDefault: boolean): BlockDefaults {
|
||||
// style gives its runs. `headingBold` is false where the registry renders the heading
|
||||
// with the file's own style, which inherits Standard rather than the bold built-in.
|
||||
function blockDefaults(baseRun: RunProps, headingLevel: number | null, boldByDefault: boolean, headingBold = true): BlockDefaults {
|
||||
const fonts = new Set(headingLevel != null ? DEFAULT_HEADING_FONTS : DEFAULT_FONTS);
|
||||
const family = baseRun.font?.toLowerCase();
|
||||
if (family) {
|
||||
@@ -764,7 +765,7 @@ function blockDefaults(baseRun: RunProps, headingLevel: number | null, boldByDef
|
||||
return {
|
||||
fontSizePt: baseRun.sizeHalfPt != null ? baseRun.sizeHalfPt / 2
|
||||
: headingLevel != null ? HEADING_SIZES[headingLevel - 1] : BODY_FONT_SIZE_PT,
|
||||
boldByDefault: baseRun.bold ?? (headingLevel != null || boldByDefault),
|
||||
boldByDefault: baseRun.bold ?? ((headingLevel != null && headingBold) || boldByDefault),
|
||||
fonts,
|
||||
color: hexColor(baseRun.color) ?? '#000000',
|
||||
italic: baseRun.italic ?? (headingLevel != null && HEADING_ITALIC[headingLevel - 1]),
|
||||
@@ -989,7 +990,10 @@ function convertParagraph(el: Element, ctx: Ctx, kind: BlockKind, boldByDefault:
|
||||
// A block that cannot carry its style's name — one in a cell or a text box — has to
|
||||
// carry the formatting instead, so it is measured against the default style: the only
|
||||
// thing re-applied on import. A caption's italic and colour live nowhere else.
|
||||
const defaults = blockDefaults(name ? baseRun : ctx.styles.paragraphRun(ctx.styles.defaultParagraphStyle()), level, boldByDefault);
|
||||
// A heading the file gives its own style is rendered by that style, and a style based
|
||||
// on another one inherits Standard — so its runs' bold is formatting, not the default.
|
||||
const headingBold = !ctx.styles.styleBasedOn(styleId);
|
||||
const defaults = blockDefaults(name ? baseRun : ctx.styles.paragraphRun(ctx.styles.defaultParagraphStyle()), level, boldByDefault, headingBold);
|
||||
// A run inherits the block's own size, not the default style's, so that is what it is
|
||||
// measured against — else a size the block overrides is suppressed and lost (odt.ts).
|
||||
const ownSizePt = blockDefaults(baseRun, level, boldByDefault).fontSizePt;
|
||||
|
||||
@@ -306,6 +306,12 @@ export class DocxStyles {
|
||||
return out;
|
||||
}
|
||||
|
||||
// The style's own w:basedOn, i.e. the parent the registry gives it in place of the
|
||||
// built-in's (collectStyleSheet). Null where the file declares neither.
|
||||
styleBasedOn(styleId: string | null): string | null {
|
||||
return (styleId && this.basedOn.get(styleId)) ?? null;
|
||||
}
|
||||
|
||||
// Character styles defined in the file (w:type="character"), for the style registry.
|
||||
namedCharacterStyles(): Map<string, string> {
|
||||
return new Map(this.charStyleNames);
|
||||
|
||||
@@ -1340,7 +1340,9 @@ function blockDefaults(resolver: StyleResolver, named: string | null, headingLev
|
||||
textAlign: null,
|
||||
keepNext: false,
|
||||
keepLines: false,
|
||||
boldByDefault: headingLevel != null || boldByDefault,
|
||||
// A heading is bold by default only where the registry keeps the bold built-in: a
|
||||
// file that declares its own heading style re-parents it, so bold is formatting.
|
||||
boldByDefault: (headingLevel != null && !resolver.styleParent(named)) || boldByDefault,
|
||||
fonts: new Set(headingLevel != null ? DEFAULT_HEADING_FONTS : DEFAULT_FONTS),
|
||||
color: '#000000',
|
||||
italic: hdef ? hdef.italic : false,
|
||||
|
||||
@@ -206,6 +206,12 @@ export class StyleResolver {
|
||||
return out;
|
||||
}
|
||||
|
||||
// A style's own style:parent-style-name — the parent the registry gives it in place of
|
||||
// the built-in's (collectStyleSheet). Null where the file declares neither.
|
||||
styleParent(styleName: string | null, family: 'paragraph' | 'text' = 'paragraph'): string | null {
|
||||
return (styleName && this.styles.get(`${family}\0${styleName}`)?.parent) ?? null;
|
||||
}
|
||||
|
||||
// The nearest named style in a style's parent chain (an automatic style is direct
|
||||
// formatting layered on top of it); null when the chain reaches none.
|
||||
namedAncestor(styleName: string | null | undefined, family: 'paragraph' | 'text' | 'table' = 'paragraph'): string | null {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// A heading's own bold run is only "what the style supplies" where the editor's bold
|
||||
// built-in still renders it — a file declaring its own heading style re-parents it.
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { zipSync, strToU8 } from 'fflate';
|
||||
import { importDocx } from '../../src/lib/import/docx';
|
||||
import { importOdt } from '../../src/lib/import/odt';
|
||||
import { buildOdt } from '../../src/lib/export/odt';
|
||||
|
||||
const W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
|
||||
|
||||
const docx = (styles: string) => zipSync({
|
||||
'word/document.xml': strToU8(`<?xml version="1.0"?>
|
||||
<w:document xmlns:w="${W}"><w:body>
|
||||
<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr>
|
||||
<w:r><w:rPr><w:b/></w:rPr><w:t>Title</w:t></w:r></w:p>
|
||||
</w:body></w:document>`),
|
||||
'word/styles.xml': strToU8(`<?xml version="1.0"?>
|
||||
<w:styles xmlns:w="${W}">
|
||||
<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style>
|
||||
${styles}
|
||||
</w:styles>`),
|
||||
});
|
||||
|
||||
const HEADING_BASED = '<w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/>'
|
||||
+ '<w:basedOn w:val="Normal"/><w:pPr><w:outlineLvl w:val="0"/></w:pPr>'
|
||||
+ '<w:rPr><w:sz w:val="40"/></w:rPr></w:style>';
|
||||
const HEADING_ROOT = HEADING_BASED.replace('<w:basedOn w:val="Normal"/>', '');
|
||||
|
||||
const marks = (doc: any) => JSON.stringify(doc.content.content[0].content[0].marks ?? []);
|
||||
|
||||
describe('heading bold', () => {
|
||||
it('keeps the run mark where the file re-parents its heading style (DOCX)', () => {
|
||||
expect(marks(importDocx(docx(HEADING_BASED)))).toContain('bold');
|
||||
});
|
||||
|
||||
it('drops it where the bold built-in renders the heading (DOCX)', () => {
|
||||
expect(marks(importDocx(docx(HEADING_ROOT)))).not.toContain('bold');
|
||||
});
|
||||
|
||||
it('survives the ODT round trip under a re-parented heading style', async () => {
|
||||
const res: any = importDocx(docx(HEADING_BASED));
|
||||
const m = { top: 2, bottom: 2, left: 2, right: 2 };
|
||||
const back: any = importOdt(await buildOdt(res.content, m, 'portrait', undefined, undefined, 'A4', res.styles));
|
||||
expect(marks(back)).toContain('bold');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user