diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index 366f2fd..5aa22eb 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -4,7 +4,7 @@ import { TableOfContents, Table, TableRow, TableCell, Header, Footer, PageNumber, SimpleField, ImportedXmlComponent, CommentRangeStart, CommentRangeEnd, CommentReference, InsertedTextRun, DeletedTextRun, - AlignmentType, LevelFormat, UnderlineType, BorderStyle, ShadingType, + AlignmentType, LevelFormat, LevelSuffix, UnderlineType, BorderStyle, ShadingType, WidthType, HeightRule, PageOrientation, LineRuleType, LineNumberRestartFormat, TableLayoutType, SectionType, NumberFormat, HorizontalPositionAlign, VerticalPositionRelativeFrom, HorizontalPositionRelativeFrom, TextWrappingType, TextWrappingSide, TabStopType, LeaderType, @@ -345,12 +345,26 @@ class Numbering { const from = Math.max(1, l + 1 - Math.max(1, def.displayLevels) + 1); const chain = []; for (let k = from; k <= l + 1; k++) if (outline[k - 1]?.format !== 'none') chain.push(`%${k}`); + // The label's own run and the indent it hangs out of; a level with no stop to + // run to is followed by nothing, its gap already part of the label text. + const run = def.labelText ? runPropsOf(def.labelText) : {}; + const indent: Writable = {}; + if (def.indentCm) indent.left = cmToTwip(def.indentCm); + if (def.firstIndentCm) { + if (def.firstIndentCm < 0) indent.hanging = cmToTwip(-def.firstIndentCm); + else indent.firstLine = cmToTwip(def.firstIndentCm); + } + const style: { run?: IRunStylePropertiesOptions; paragraph?: { indent: IIndentAttributesProperties } } = {}; + if (Object.keys(run).length) style.run = run; + if (Object.keys(indent).length) style.paragraph = { indent }; levels.push({ level: l, format: ORDERED_FORMAT[def.format] ?? LevelFormat.DECIMAL, text: `${def.prefix}${chain.join('.')}${def.suffix || ' '}`, start: def.start ?? 1, alignment: AlignmentType.LEFT, + suffix: def.tabCm == null ? LevelSuffix.NOTHING : LevelSuffix.TAB, + ...(Object.keys(style).length ? { style } : {}), }); } this.map.set(reference, levels); @@ -2542,9 +2556,8 @@ function paragraphStyleOf(style: Style): IParagraphStyleOptions { }; } -// One registry character style → a Word character style (w:type="character"). -function characterStyleOf(style: Style): ICharacterStyleOptions { - const t = style.text; +// A run of text the model describes → Word's run properties. +function runPropsOf(t: TextProps): Writable { const run: Writable = {}; if (t.fontFamily) run.font = t.fontFamily === 'Liberation Serif' ? DOC_FONT : t.fontFamily; if (t.fontSizePt != null) run.size = Math.round(t.fontSizePt * 2); @@ -2555,7 +2568,12 @@ function characterStyleOf(style: Style): ICharacterStyleOptions { if (t.underline) run.underline = {}; if (t.strike) run.strike = t.strike; if (t.color) run.color = t.color.replace('#', ''); - return { id: docxStyleId(style.name), name: style.name, quickFormat: true, run }; + return run; +} + +// One registry character style → a Word character style (w:type="character"). +function characterStyleOf(style: Style): ICharacterStyleOptions { + return { id: docxStyleId(style.name), name: style.name, quickFormat: true, run: runPropsOf(style.text) }; } // Word needs a referenced table style to exist. ODF/our model hold the banding, and the diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index 691002a..e827dd7 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -17,7 +17,7 @@ import { TABLE_REGIONS, parseTableLook, regionText, type TableLook, type TableRegion, } from '../styles/tableStyles'; import { effectiveListLevel, listStyleMarginCm, listStyleOverridden, MAX_LIST_LEVELS, type ListStyle } from '../styles/listStyles'; -import { outlineIsEmpty, type OutlineNumbering } from '../styles/outlineNumbering'; +import { outlineIsEmpty, type OutlineLevel, type OutlineNumbering } from '../styles/outlineNumbering'; // A table's named style plus the conditional areas it opts into (Word's Table Style // Options), collected by exportTable in document order. @@ -1984,11 +1984,30 @@ function outlineStyleXml(outline: OutlineNumbering | null | undefined): string { if (level.displayLevels > 1) attrs['text:display-levels'] = String(level.displayLevels); if (level.start !== 1) attrs['text:start-value'] = String(level.start); if (level.charStyle) attrs['text:style-name'] = odfStyleName(level.charStyle); - levels.push(setTagAttrs('', attrs)); + const tag = setTagAttrs('', attrs); + const pos = outlineLevelPositionXml(level); + levels.push(pos ? `${tag.replace('/>', '>')}${pos}` : tag); }); return levels.length ? `${levels.join('')}` : ''; } +// Where the level puts its label — the label-alignment mode both products write today. +// A level the file gave no position keeps none, so nothing is invented for it. +function outlineLevelPositionXml(level: OutlineLevel): string { + const indent = level.indentCm ?? 0; + const first = level.firstIndentCm ?? 0; + const tab = level.tabCm ?? null; + if (!indent && !first && tab == null) return ''; + const attrs: Record = { + 'text:label-followed-by': tab == null ? 'nothing' : 'listtab', + 'fo:text-indent': `${first}cm`, + 'fo:margin-left': `${indent}cm`, + }; + if (tab != null) attrs['text:list-tab-stop-position'] = `${tab}cm`; + return '' + + `${setTagAttrs('', attrs)}`; +} + // Resolve STY sentinels: point each marked block at its named style — directly when it // carries no direct formatting, else via a clone of its automatic style whose parent // becomes the named one (so hard formatting keeps overriding the style, as in LO). diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index adbadcf..c7b4c9b 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -937,12 +937,21 @@ function outlineFromDocx(ctx: Ctx): OutlineNumbering | null { const text = def.lvlText ?? ''; const holders = [...text.matchAll(/%\d/g)]; const last = holders[holders.length - 1]; + // The label's own run, and where Word hangs it: the level's indent is the stop its + // tab runs to, the first line the place the label itself starts. + const labelText = def.run ? runTextProps(def.run) : null; + const indentCm = def.leftTwip != null ? round2(twipToCm(def.leftTwip)) : 0; + const firstCm = def.hangingTwip != null ? round2(-twipToCm(def.hangingTwip)) : 0; out.push({ format, prefix: holders.length ? text.slice(0, holders[0].index) : '', - suffix: last ? text.slice((last.index ?? 0) + 2) : '', + suffix: (last ? text.slice((last.index ?? 0) + 2) : '') + (def.suffix === 'space' ? ' ' : ''), displayLevels: Math.max(1, holders.length), start: def.start ?? 1, + ...(labelText && Object.keys(labelText).length ? { labelText } : {}), + ...(indentCm ? { indentCm } : {}), + ...(firstCm ? { firstIndentCm: firstCm } : {}), + ...(def.suffix === 'space' || def.suffix === 'nothing' ? {} : { tabCm: indentCm }), }); } // Trailing unnumbered levels carry nothing — a definition ends at its last number. diff --git a/src/lib/import/docxStyles.ts b/src/lib/import/docxStyles.ts index 2ed9508..f7d1718 100644 --- a/src/lib/import/docxStyles.ts +++ b/src/lib/import/docxStyles.ts @@ -44,7 +44,10 @@ export type RunProps = { // A numbering level definition (numbering.xml w:lvl). bulletFont is the level's // w:rPr/w:rFonts (Wingdings/Symbol give the bullet glyph its meaning). -export type LevelDef = { numFmt?: string; lvlText?: string; leftTwip?: number; start?: number; bulletFont?: string; rightAligned?: boolean }; +export type LevelDef = { + numFmt?: string; lvlText?: string; leftTwip?: number; hangingTwip?: number; start?: number; + bulletFont?: string; rightAligned?: boolean; suffix?: string; run?: RunProps; +}; // Paragraph spacing from a w:pPr/w:spacing (only the attributes actually present, so // omitted ones inherit up the style chain). before/after in twips, line per w:line. @@ -365,7 +368,18 @@ export class DocxStyles { const txt = firstChild(lvl, 'lvlText'); if (txt) def.lvlText = wVal(txt) ?? undefined; const start = firstChild(lvl, 'start'); if (start) { const n = parseInt(wVal(start) ?? '', 10); if (Number.isFinite(n)) def.start = n; } const ind = firstChild(lvl, 'pPr') && firstChild(firstChild(lvl, 'pPr')!, 'ind'); - if (ind) { const l = parseInt(ind.getAttributeNS(W, 'left') ?? ind.getAttributeNS(W, 'start') ?? '', 10); if (Number.isFinite(l)) def.leftTwip = l; } + if (ind) { + const l = parseInt(ind.getAttributeNS(W, 'left') ?? ind.getAttributeNS(W, 'start') ?? '', 10); + if (Number.isFinite(l)) def.leftTwip = l; + // A first line past the indent is a negative hang, which is how Word writes a + // label sitting right of the text it labels. + const h = parseInt(ind.getAttributeNS(W, 'hanging') ?? '', 10); + const f = parseInt(ind.getAttributeNS(W, 'firstLine') ?? '', 10); + if (Number.isFinite(h)) def.hangingTwip = h; + else if (Number.isFinite(f)) def.hangingTwip = -f; + } + const suff = firstChild(lvl, 'suff'); if (suff) def.suffix = wVal(suff) ?? undefined; + const rPr = firstChild(lvl, 'rPr'); if (rPr) def.run = parseRunProps(rPr); const jc = firstChild(lvl, 'lvlJc'); if (jc && (wVal(jc) === 'right' || wVal(jc) === 'end')) def.rightAligned = true; const rf = firstChild(lvl, 'rPr') && firstChild(firstChild(lvl, 'rPr')!, 'rFonts'); diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index 02d5e75..cd76ae8 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -2,7 +2,7 @@ import { unzipSync, strFromU8 } from 'fflate'; import { StyleResolver, NS, WATERMARK_NAME, lengthToPt, lengthToCm, layerTextProps, type PropMap } from './styleResolver'; import { HEADING_STYLE_OVERRIDES, MAX_HEADING_LEVEL, ODF_LOOK_ATTRS, normalizeColor } from '../export/odt'; import { builtinStyleSheet, DEFAULT_STYLE, type ParaProps, type Style, type StyleSheet, type TextProps } from '../styles/styleSheet'; -import { DEFAULT_OUTLINE_LEVEL, MAX_OUTLINE_LEVELS, type OutlineNumbering } from '../styles/outlineNumbering'; +import { DEFAULT_OUTLINE_LEVEL, MAX_OUTLINE_LEVELS, type OutlineLevel, type OutlineNumbering } from '../styles/outlineNumbering'; import { LIST_LEVEL_STEP_CM, MAX_LIST_LEVELS, type ListLevelStyle, type ListStyle } from '../styles/listStyles'; import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow'; import { fitInlineImage, framePx } from '../editor/extensions/image'; @@ -1534,6 +1534,12 @@ function collectStyleSheet(resolver: StyleResolver, ctx: Ctx): StyleSheet { if (level) style.outlineLevel = Number(level[1]); sheet.paragraph[name] = style; } + // A chapter number's character style is named by the outline definition, not by any + // run, so the walk never sees it — and the export would reference a style it dropped. + for (const level of Array.from(resolver.outlineStyle()?.children ?? [])) { + const named = level.getAttributeNS(NS.text, 'style-name'); + if (named) ctx.usedCharStyles.add(named); + } for (const odfName of ctx.usedCharStyles) { const name = ctx.charStyleNames.get(odfName) ?? odfName; const builtin = sheet.character[name]; @@ -1568,13 +1574,18 @@ function outlineFromOdf(el: Element | null, ctx: Ctx): OutlineNumbering | null { } numbered = true; const charStyle = def.getAttributeNS(NS.text, 'style-name'); + const name = charStyle ? ctx.charStyleNames.get(charStyle) ?? charStyle : null; + const labelText = charStyle ? textPropsFromOdf(ctx.resolver.spanTextProps(charStyle), ctx.resolver) : null; + const geometry = outlineLevelGeometry(def); out.push({ format: format as NoteNumFormat, prefix: def.getAttributeNS(NS.style, 'num-prefix') ?? '', - suffix: def.getAttributeNS(NS.style, 'num-suffix') ?? '', + suffix: (def.getAttributeNS(NS.style, 'num-suffix') ?? '') + geometry.pad, displayLevels: Math.max(1, parseInt(def.getAttributeNS(NS.text, 'display-levels') ?? '1', 10) || 1), start: Math.max(0, parseInt(def.getAttributeNS(NS.text, 'start-value') ?? '1', 10) || 1), - ...(charStyle ? { charStyle: ctx.charStyleNames.get(charStyle) ?? charStyle } : {}), + ...(name ? { charStyle: name } : {}), + ...(labelText && Object.keys(labelText).length ? { labelText } : {}), + ...geometry.pos, }); } // Trailing unnumbered levels carry nothing — a definition ends at its last number. @@ -1582,6 +1593,36 @@ function outlineFromOdf(el: Element | null, ctx: Ctx): OutlineNumbering | null { return numbered ? out : null; } +// Where a level puts its label: the newer label-alignment mode, else the positional one +// (text:space-before + text:min-label-width). A label followed by a space carries it in +// its suffix, since only a tab has a stop to run to. +function outlineLevelGeometry(def: Element): { pos: Partial; pad: string } { + const props = def.getElementsByTagNameNS(NS.style, 'list-level-properties')[0] ?? null; + const align = props?.getElementsByTagNameNS(NS.style, 'list-level-label-alignment')[0] ?? null; + const pos: Partial = {}; + let pad = ''; + const cm = (v: number | null) => (v == null ? null : Math.round(v * 1000) / 1000); + if (align) { + const indent = cm(lengthToCm(align.getAttributeNS(NS.fo, 'margin-left'))) ?? 0; + const first = cm(lengthToCm(align.getAttributeNS(NS.fo, 'text-indent'))) ?? 0; + const followedBy = align.getAttributeNS(NS.text, 'label-followed-by') ?? 'listtab'; + if (indent) pos.indentCm = indent; + if (first) pos.firstIndentCm = first; + if (followedBy === 'listtab') { + pos.tabCm = cm(lengthToCm(align.getAttributeNS(NS.text, 'list-tab-stop-position'))) ?? indent; + } else if (followedBy === 'space') pad = ' '; + } else if (props) { + const before = cm(lengthToCm(props.getAttributeNS(NS.text, 'space-before'))) ?? 0; + const width = cm(lengthToCm(props.getAttributeNS(NS.text, 'min-label-width'))) ?? 0; + if (before || width) { + pos.indentCm = Math.round((before + width) * 1000) / 1000; + pos.firstIndentCm = -width; + pos.tabCm = pos.indentCm; + } + } + return { pos, pad }; +} + // A named element → the registry's shape. Each level's indentCm is // its margin step past the level above minus the 1.27cm base (the export's inverse). function listStyleFromOdf(name: string, el: Element, builtin?: boolean): ListStyle { diff --git a/src/lib/styles/CLAUDE.md b/src/lib/styles/CLAUDE.md index 802c36b..ec09daf 100644 --- a/src/lib/styles/CLAUDE.md +++ b/src/lib/styles/CLAUDE.md @@ -148,3 +148,12 @@ holds it as a reactive singleton persisted to `edentext-styles` (same shape as ` styles, Word as a multilevel numbering the heading styles point at (`w:numPr` on the style, `w:pStyle` in the level) — the DOCX pass mints that numbering's `w:num` itself, since no paragraph references it. + A level also carries **how the label is set and where it sits**: the character style + the file names (resolved into `labelText`, since Word's `w:lvl/w:rPr` names none), the + paragraph's indent, its first line and the stop the label's tab runs to. The label is + an *inline block* so one set larger than its heading grows the line as both products + grow it, with the stop as its minimum width; `text-indent` inherits into that block + and is zeroed there, or the hanging indent would shrink the label's own box by it. + Probed: a label wider than its stop overruns it, and LibreOffice then advances to the + paragraph's next tab stop (its own, else the 1.25cm grid, measured from the indent) — + the editor butts the title against the label instead, up to one grid step short. diff --git a/src/lib/styles/outlineNumbering.ts b/src/lib/styles/outlineNumbering.ts index 1c4acd3..17d5b37 100644 --- a/src/lib/styles/outlineNumbering.ts +++ b/src/lib/styles/outlineNumbering.ts @@ -3,6 +3,7 @@ // One definition per document, level 1 first (ODF text:outline-style). import type { NoteNumFormat } from '../storage/noteSettings'; +import type { TextProps } from './styleSheet'; export type OutlineLevel = { // 'none' = this level is unnumbered, which is how both products switch a level off. @@ -14,6 +15,15 @@ export type OutlineLevel = { start: number; // The character style the label takes, where the file names one. charStyle?: string | null; + // That style resolved, plus Word's w:lvl/w:rPr, which names no style: a chapter + // number is often set in a size and colour of its own. + labelText?: TextProps | null; + // Where the two products put label and title, cm from the text margin: the + // paragraph's indent, its first line relative to that (negative hangs the label + // out of it) and the stop the label's tab runs to. + indentCm?: number; + firstIndentCm?: number; + tabCm?: number | null; }; export type OutlineNumbering = OutlineLevel[]; @@ -40,6 +50,10 @@ const CSS_STYLE: Record = { const COUNTER = (level: number) => `edt-outline-${level}`; const quote = (s: string) => `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; +// A heading in a cell, a list item or a frame is not part of the chapter count — +// neither product numbers one, so no rule below reaches it. +const LOOSE = ':not(:is(td, th, li, .frame-node) *)'; + // The label a heading shows, as the parts of a CSS `content` value. The parent levels a // display-levels chain shows are joined by '.', the way both products render one. function labelContent(outline: OutlineNumbering, level: number): string | null { @@ -56,7 +70,10 @@ function labelContent(outline: OutlineNumbering, level: number): string | null { parts.push(`counter(${COUNTER(l)}, ${CSS_STYLE[at.format]})`); } if (!parts.length) return null; - return [own.prefix ? quote(own.prefix) : '', ...parts, quote(own.suffix || ' ')] + // No-break spaces: a trailing one hangs at the end of the label's box and would take + // the gap the file puts between label and title out of the measurement. + const gap = (s: string) => quote(s.replace(/ /g, '\u00a0')); + return [own.prefix ? gap(own.prefix) : '', ...parts, gap(own.suffix || ' ')] .filter(Boolean).join(' '); } @@ -80,9 +97,14 @@ export function outlineLabel( return `${own.prefix}${parts.join('.')}${own.suffix || ' '}`; } +const cm = (v: number) => `${Math.round(v * 1000) / 1000}cm`; + // The counters that draw the labels. A heading increments its own level and resets every // deeper one, which is what makes 2.1.4 restart at every new chapter. -export function outlineCss(outline: OutlineNumbering | null | undefined): string { +export function outlineCss( + outline: OutlineNumbering | null | undefined, + labelDecls: (t: TextProps) => string[] = () => [], +): string { if (outlineIsEmpty(outline)) return ''; const levels = outline!; const rules: string[] = []; @@ -91,18 +113,34 @@ export function outlineCss(outline: OutlineNumbering | null | undefined): string .filter(Boolean).join(' '); rules.push(`.paper .tiptap {\n counter-reset: ${resets};\n}`); for (let level = 1; level <= Math.min(MAX_OUTLINE_LEVELS, levels.length); level++) { - if (levels[level - 1]?.format === 'none') continue; + const own = levels[level - 1]; + if (!own || own.format === 'none') continue; + const head = `.paper .tiptap h${level}${LOOSE}`; 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(' '); - rules.push(`.paper .tiptap h${level} {\n counter-increment: ${COUNTER(level)};${deeper ? `\n counter-reset: ${deeper};` : ''}\n}`); + const block = [`counter-increment: ${COUNTER(level)}`]; + if (deeper) block.push(`counter-reset: ${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; + const first = own.firstIndentCm ?? 0; + if (indent) block.push(`margin-left: calc(var(--sec-inset-left, 0px) + ${cm(indent)})`); + if (first) block.push(`text-indent: ${cm(first)}`); + rules.push(`${head} {\n ${block.join(';\n ')};\n}`); + const content = labelContent(levels, level); - if (content) rules.push(`.paper .tiptap h${level}::before {\n content: ${content};\n white-space: pre;\n}`); + if (!content) continue; + // An inline block, so a label set larger than its heading grows the line the way + // both products grow it; its min width is the tab the label is followed by, which + // a label wider than the stop simply overruns. + // text-indent inherits, and an inline block applies it to its own first line: the + // hanging indent below would shrink the label's box by exactly what it hangs it out. + const label = [`content: ${content}`, 'white-space: pre', 'display: inline-block', 'text-indent: 0']; + const gap = (own.tabCm ?? 0) - (indent + first); + if (gap > 0) label.push(`min-width: ${cm(gap)}`); + label.push(...labelDecls(own.labelText ?? {})); + rules.push(`${head}::before {\n ${label.join(';\n ')};\n}`); } - // A heading in a cell, a list item or a frame is not part of the chapter count — - // neither product numbers one. - const hs = Array.from({ length: MAX_OUTLINE_LEVELS }, (_, i) => `h${i + 1}`).join(', '); - rules.push(`.paper .tiptap :is(td, th, li, .frame-node) :is(${hs}) {\n counter-increment: none;\n}`); - rules.push(`.paper .tiptap :is(td, th, li, .frame-node) :is(${hs})::before {\n content: none;\n}`); return rules.join('\n\n'); } diff --git a/src/lib/styles/styleSheet.ts b/src/lib/styles/styleSheet.ts index 36fcafd..3a4b0eb 100644 --- a/src/lib/styles/styleSheet.ts +++ b/src/lib/styles/styleSheet.ts @@ -343,7 +343,7 @@ export function styleCss(sheet: StyleSheet): string { // Table styles last: their cell selectors must outrank the paragraph rules above. const table = tableStyleCss(sheet.table ?? {}); if (table) rules.push(table); - const outline = outlineCss(sheet.outline); + const outline = outlineCss(sheet.outline, (t) => textDeclarations(t)); if (outline) rules.push(outline); return rules.join('\n\n'); } diff --git a/tests/unit/outline-numbering.test.ts b/tests/unit/outline-numbering.test.ts index 35043cc..bd67cd9 100644 --- a/tests/unit/outline-numbering.test.ts +++ b/tests/unit/outline-numbering.test.ts @@ -4,7 +4,7 @@ import { importOdt } from '../../src/lib/import/odt'; import { importDocx } from '../../src/lib/import/docx'; import { buildOdt } from '../../src/lib/export/odt'; import { buildDocx } from '../../src/lib/export/docx'; -import { builtinStyleSheet } from '../../src/lib/styles/styleSheet'; +import { builtinStyleSheet, styleCss } from '../../src/lib/styles/styleSheet'; import { outlineCss, outlineLabel, type OutlineNumbering } from '../../src/lib/styles/outlineNumbering'; import { formatOrdinal } from '../../src/lib/utils/orderedListTypes'; @@ -36,6 +36,27 @@ function odtWithOutline(): Uint8Array { return zipSync({ 'content.xml': strToU8(content), 'styles.xml': strToU8(styles) }); } +// The shape LibreOffice writes for a chapter number of its own size: a character style +// on the level, and the label hung out of the paragraph's indent. +function odtWithGeometry(): Uint8Array { + const styles = ` + + + + + + + + + + `; + const content = ` + One + `; + return zipSync({ 'content.xml': strToU8(content), 'styles.xml': strToU8(styles) }); +} + const headingDoc = { type: 'doc', content: [1, 2, 3].map((level) => ({ @@ -77,8 +98,41 @@ 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'); - expect(css).toMatch(/h2::before \{\n {2}content: counter\(edt-outline-1, decimal\) "\." counter\(edt-outline-2, decimal\) " ";/); + 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('counter-increment: none'); + expect(css).toContain(':not(:is(td, th, li, .frame-node) *)'); + }); + + it('reads the label position and the style it is set in', () => { + const level = importOdt(odtWithGeometry()).styles?.outline?.[0]; + expect(level?.indentCm).toBeCloseTo(0.762, 3); + expect(level?.firstIndentCm).toBeCloseTo(-0.762, 3); + expect(level?.tabCm).toBeCloseTo(0.762, 3); + expect(level?.charStyle).toBe('Chapter Number'); + expect(level?.labelText).toMatchObject({ fontSizePt: 96, color: '#B2B2B2' }); + }); + + it('hangs the label out of the indent, in the size the file gives it', () => { + const sheet = importOdt(odtWithGeometry()).styles!; + const css = styleCss(sheet); + expect(css).toMatch(/h1[^{]*\{\n[^}]*margin-left: calc\(var\(--sec-inset-left, 0px\) \+ 0\.762cm\)/); + expect(css).toMatch(/h1[^{]*\{\n[^}]*text-indent: -0\.762cm/); + // The stop the label's tab runs to is its minimum width; a wider label overruns it. + expect(css).toMatch(/h1[^{]*::before \{\n[^}]*min-width: 0\.762cm/); + expect(css).toMatch(/h1[^{]*::before \{\n[^}]*font-size: 96pt/); + }); + + it('carries the label position through both formats', async () => { + const sheet = { ...builtinStyleSheet(), outline: importOdt(odtWithGeometry()).styles!.outline }; + const odt = await buildOdt(headingDoc as never, margins, 'portrait', undefined, null, 'A4', sheet); + expect(strFromU8(unzipSync(odt)['styles.xml'])).toContain('text:list-tab-stop-position="0.762cm"'); + expect(importOdt(odt).styles?.outline?.[0]).toMatchObject({ indentCm: 0.762, firstIndentCm: -0.762, tabCm: 0.762 }); + + const docx = await buildDocx(headingDoc as never, margins, 'portrait', undefined, null, 'A4', sheet); + const back = importDocx(docx).styles?.outline?.[0]; + // Word's twips round the same tenth of a millimetre both ways. + expect(back?.indentCm).toBeCloseTo(0.762, 2); + expect(back?.firstIndentCm).toBeCloseTo(-0.762, 2); + expect(back?.labelText).toMatchObject({ fontSizePt: 96, color: '#B2B2B2' }); }); });