diff --git a/src/lib/import/CLAUDE.md b/src/lib/import/CLAUDE.md
index 0d13c51..35bbed4 100644
--- a/src/lib/import/CLAUDE.md
+++ b/src/lib/import/CLAUDE.md
@@ -8,6 +8,7 @@
The **Open .odt** button (`App.svelte`) parses an uploaded file with `importOdt(bytes)` and replaces the document via `setContent` (after a confirm when the current doc is non-empty), also adopting the file's page margins/orientation. It parses `content.xml`/`styles.xml` directly (fflate + `DOMParser`) rather than odf-kit's reader, which flattens cell blocks and drops row heights / list number formats.
- **`StyleResolver`** flattens ODF style indirection: named + automatic styles from both XML files, `style:parent-style-name` chains rooted in `style:default-style`, font-face declarations (`style:font-name` → family), `rel-column-width`/`column-width`, `min-row-height`, list styles, and page geometry. `fo:font-family` and `style:font-name` shadow each other across chain levels (`layerTextProps`). A **percentage `fo:font-size`** (`"130%"` — how LibreOffice defines its heading styles) is resolved against the inherited size while walking the chain (`resolvePercentSize`); without it the file's size is dropped and our default silently replaces it.
+- **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.
- **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`; other presets → "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 (`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. **SVM/WMF/EMF** have no JS decoder (SVM is StarOffice-proprietary) → skipped with the format warning; LibreOffice/WASM conversion was ruled out (no backend; WASM LO is ~300MB).
diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts
index 1536c50..6f7ce03 100644
--- a/src/lib/import/docx.ts
+++ b/src/lib/import/docx.ts
@@ -729,15 +729,16 @@ function convertParagraph(el: Element, ctx: Ctx, kind: BlockKind, boldByDefault:
const stops = formatTabStops(directTabs ? readTabStops(directTabs) : ctx.styles.paragraphTabs(styleId));
if (stops) attrs.tabStops = stops;
const baseRun = ctx.styles.paragraphRun(styleId);
- const defaults = blockDefaults(baseRun, level, boldByDefault);
+ const name = styleId && kind === 'body' ? ctx.styleNames.get(styleId) : undefined;
+ // 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);
const content = convertInline(el, ctx, baseRun, defaults, false);
- if (styleId && kind === 'body') {
- const name = ctx.styleNames.get(styleId);
- if (name) {
- ctx.usedStyles.add(styleId);
- if (name !== (level ? `Heading ${level}` : DEFAULT_STYLE)) attrs.styleName = name;
- }
+ if (name) {
+ ctx.usedStyles.add(styleId!);
+ if (name !== (level ? `Heading ${level}` : DEFAULT_STYLE)) attrs.styleName = name;
}
// The paragraph mark's own run props (w:pPr/w:rPr) set the line-height floor for
diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts
index 14262f9..e2dc637 100644
--- a/src/lib/import/odt.ts
+++ b/src/lib/import/odt.ts
@@ -1000,7 +1000,10 @@ function convertParaLike(el: Element, ctx: Ctx, kind: BlockKind, boldByDefault =
// The file's own named style for this block (an automatic style is direct formatting
// layered on top of it); everything it already provides is not direct formatting.
const named = resolver.namedAncestor(styleName);
- const defaults = blockDefaults(resolver, named, isHeading ? level : null, boldByDefault);
+ // Only a body block carries its style's name (below); anywhere else — a cell, a text
+ // box — the formatting has to become direct, so it is measured against the default
+ // style instead: the only one re-applied on import.
+ const defaults = blockDefaults(resolver, kind === 'body' ? named : null, isHeading ? level : null, boldByDefault);
const attrs = blockAttrs(paraProps, baseTextProps, defaults, kind);
// A style:master-page-name switches the page master, which is how ODF gives a section