From ae836a939d10fd5173cd48c48e8d6abd1080b080 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Fri, 4 Sep 2026 14:22:31 +0100 Subject: [PATCH 1/3] feat(ppt-codec): read and write a paragraph's spacing and margins TextPFException carries lineSpacing, spaceBefore, spaceAfter, leftMargin, and indent alongside the alignment field this package already handled; every optional field now reads and writes in the spec's declared order (masks, then textAlignment, lineSpacing, spaceBefore, spaceAfter, leftMargin, indent). ParaSpacing has two mutually exclusive forms: a non-negative percentage-of-line-height value, or a negative absolute master-units value. document-schema.js's lineSpacing (a line-height multiplier) can only express the percentage form; spacingBeforePt/spacingAfterPt (plain points) can only express the absolute form. A paragraph stating the other form of either field reports no value for it rather than a wrong one. MarginOrIndent has no such ambiguity: leftMargin and indent are always absolute master-unit offsets, converting cleanly to indentLeftPt/indentFirstLinePt in both directions, including a negative (hanging) first-line indent. --- packages/ppt-codec/src/content-write.ts | 24 ++++++++++++++ packages/ppt-codec/src/content.ts | 27 ++++++++++++++++ packages/ppt-codec/src/text/style-write.ts | 36 ++++++++++++++++++--- packages/ppt-codec/src/text/style.ts | 37 +++++++++++++++------- 4 files changed, 108 insertions(+), 16 deletions(-) diff --git a/packages/ppt-codec/src/content-write.ts b/packages/ppt-codec/src/content-write.ts index 6b0682a37..d73126ad1 100644 --- a/packages/ppt-codec/src/content-write.ts +++ b/packages/ppt-codec/src/content-write.ts @@ -17,6 +17,7 @@ import { type StyleTextProps, } from "./text/style"; import { LINE_BREAK, PARAGRAPH_SEPARATOR } from "./text/atoms"; +import { pointsToMasterUnits } from "./units"; // The write-side mirror of content.ts: given a shape's ContentBlock list, produces the flat character-counted text body and the StyleTextProps runs [MS-PPT]'s StyleTextPropAtom carries alongside it -- the inverse of content.ts's buildParagraphs, which turns that same pairing back into ContentParagraph[]. Only 'paragraph' blocks contribute text; every other ContentBlock kind (image, table, embeddedObject, pageBreak, the two construct markers) is silently excluded from the written text body, the same documented-gap convention the reader's own README already uses for constructs it does not surface -- ppt-codec's writer covers text-box slides, not the full ContentBlock vocabulary. @@ -39,6 +40,18 @@ function mapAlignmentToPpt( } } +/** document-schema.js's lineSpacing is always a positive multiple of single line height (the schema's own z.number().positive()), so this always writes ParaSpacing's percentage form -- there is no master-units case to choose between, unlike the read side's two-way branch. */ +function lineSpacingToParaSpacing( + multiple: number | undefined, +): number | undefined { + return multiple === undefined ? undefined : Math.round(multiple * 100); +} + +/** document-schema.js's spacingBeforePt/spacingAfterPt are always plain points, so this always writes ParaSpacing's negative (absolute master-units) form -- the percentage-of-line-height form has no point value to derive it from. */ +function pointsToParaSpacing(pt: number | undefined): number | undefined { + return pt === undefined ? undefined : -pointsToMasterUnits(pt); +} + function mapColorToPpt(color: Color | undefined): RgbColor | undefined { if (color === undefined) { return undefined; @@ -111,6 +124,17 @@ export function buildTextBody( properties: { indentLevel: paragraph.list?.level ?? 0, alignment: mapAlignmentToPpt(paragraph.alignment), + lineSpacing: lineSpacingToParaSpacing(paragraph.lineSpacing), + spaceBefore: pointsToParaSpacing(paragraph.spacingBeforePt), + spaceAfter: pointsToParaSpacing(paragraph.spacingAfterPt), + leftMargin: + paragraph.indentLeftPt === undefined + ? undefined + : pointsToMasterUnits(paragraph.indentLeftPt), + indent: + paragraph.indentFirstLinePt === undefined + ? undefined + : pointsToMasterUnits(paragraph.indentFirstLinePt), }, }); diff --git a/packages/ppt-codec/src/content.ts b/packages/ppt-codec/src/content.ts index 017055f91..046818386 100644 --- a/packages/ppt-codec/src/content.ts +++ b/packages/ppt-codec/src/content.ts @@ -16,6 +16,7 @@ import { type StyleRun, type StyleTextProps, } from "./text/style"; +import { masterUnitsToPoints } from "./units"; // The mapping from [MS-PPT]'s own text model onto document-schema.js's shared content vocabulary. The two disagree structurally: PowerPoint stores a shape's text as one flat character array with formatting expressed as character-counted runs over it, while the schema stores paragraphs each holding their own runs. Turning one into the other is an intersection of two independent partitions of the same character range -- paragraphs by separator, formatting by run count -- which is why it lives here rather than inside either reader. @@ -37,6 +38,27 @@ function mapAlignment(alignment: number | undefined): Alignment | undefined { } } +/** ParaSpacing's own percentage form (0-13200, value/100 = percent of line height) is the only one document-schema.js's lineSpacing (a plain multiple of single line height) can express -- the negative, absolute-master-units form has no multiplier to convert to without knowing the paragraph's actual rendered line height, so it maps to nothing rather than a guess. */ +function paraSpacingToLineSpacing(raw: number | undefined): number | undefined { + if (raw === undefined || raw < 0) { + return undefined; + } + return raw / 100; +} + +/** ParaSpacing's own negative (absolute master-units) form is the only one document-schema.js's spacingBeforePt/spacingAfterPt (plain points) can express -- the positive percentage-of-line-height form has no point value to convert to without knowing the actual rendered line height, so it maps to nothing rather than a guess. */ +function paraSpacingToPoints(raw: number | undefined): number | undefined { + if (raw === undefined || raw >= 0) { + return undefined; + } + return masterUnitsToPoints(-raw); +} + +/** MarginOrIndent is always an absolute signed master-unit offset, with no percentage form to disambiguate -- unlike ParaSpacing, every value converts cleanly. */ +function marginOrIndentToPoints(raw: number | undefined): number | undefined { + return raw === undefined ? undefined : masterUnitsToPoints(raw); +} + function mapColor(color: RgbColor | undefined): Color | undefined { if (color === undefined) { return undefined; @@ -133,6 +155,11 @@ export function buildParagraphs( alignment, // [MS-PPT] states an indent level on every paragraph run, including level 0, which is the ordinary un-indented body text rather than a list. Only a level above zero is reported as list membership, matching how ooxml.js reads a drawing paragraph's a:pPr/@lvl. list: indentLevel > 0 ? { level: indentLevel } : undefined, + spacingBeforePt: paraSpacingToPoints(paragraphProperties?.spaceBefore), + spacingAfterPt: paraSpacingToPoints(paragraphProperties?.spaceAfter), + lineSpacing: paraSpacingToLineSpacing(paragraphProperties?.lineSpacing), + indentLeftPt: marginOrIndentToPoints(paragraphProperties?.leftMargin), + indentFirstLinePt: marginOrIndentToPoints(paragraphProperties?.indent), }; }); } diff --git a/packages/ppt-codec/src/text/style-write.ts b/packages/ppt-codec/src/text/style-write.ts index c8416191a..e7942c5e5 100644 --- a/packages/ppt-codec/src/text/style-write.ts +++ b/packages/ppt-codec/src/text/style-write.ts @@ -10,6 +10,11 @@ import { COLOR_INDEX_SRGB, type CharacterProperties, PF_ALIGN, + PF_INDENT, + PF_LEFT_MARGIN, + PF_LINE_SPACING, + PF_SPACE_AFTER, + PF_SPACE_BEFORE, type ParagraphProperties, type RgbColor, STYLE_BOLD, @@ -25,14 +30,37 @@ function writeColorIndexStruct(color: RgbColor): Uint8Array { return new Uint8Array([color.red, color.green, color.blue, COLOR_INDEX_SRGB]); } -// A TextPFException carrying only the one field this writer ever states: textAlignment. Every other PFMasks field (bullets, margins, spacing, tab stops, wrapping, direction) is left unset, which round-trips as "the format did not say" through the reader's own undefined-on-unset-mask behaviour -- exactly the same absence a run whose writer never set the bit already produces for those fields today. +// A TextPFException carrying textAlignment, lineSpacing/spaceBefore/spaceAfter, and leftMargin/indent -- every field this writer states, in the spec's own declared order (masks, then textAlignment, lineSpacing, spaceBefore, spaceAfter, leftMargin, indent). Every other PFMasks field (bullets, tab stops, wrapping, direction) is left unset, which round-trips as "the format did not say" through the reader's own undefined-on-unset-mask behaviour -- exactly the same absence a run whose writer never set the bit already produces for those fields today. function writeTextPFException( properties: ParagraphProperties, ): Uint8Array { - if (properties.alignment === undefined) { - return u32le(0); + let masks = 0; + const fields: Uint8Array[] = []; + if (properties.alignment !== undefined) { + masks |= PF_ALIGN; + fields.push(u16le(properties.alignment)); + } + if (properties.lineSpacing !== undefined) { + masks |= PF_LINE_SPACING; + fields.push(i16le(properties.lineSpacing)); + } + if (properties.spaceBefore !== undefined) { + masks |= PF_SPACE_BEFORE; + fields.push(i16le(properties.spaceBefore)); + } + if (properties.spaceAfter !== undefined) { + masks |= PF_SPACE_AFTER; + fields.push(i16le(properties.spaceAfter)); + } + if (properties.leftMargin !== undefined) { + masks |= PF_LEFT_MARGIN; + fields.push(i16le(properties.leftMargin)); + } + if (properties.indent !== undefined) { + masks |= PF_INDENT; + fields.push(i16le(properties.indent)); } - return concatBytes(u32le(PF_ALIGN), u16le(properties.alignment)); + return concatBytes(u32le(masks), ...fields); } function writeTextCFException( diff --git a/packages/ppt-codec/src/text/style.ts b/packages/ppt-codec/src/text/style.ts index 267e6ed0e..e5b483f16 100644 --- a/packages/ppt-codec/src/text/style.ts +++ b/packages/ppt-codec/src/text/style.ts @@ -72,6 +72,13 @@ export interface RgbColor { export interface ParagraphProperties { readonly indentLevel: number; readonly alignment: number | undefined; + /** ParaSpacing ([MS-PPT]), raw and unconverted: 0-13200 is a percentage of line height (value/100 = percent), negative is the absolute value in master units. content.ts's own paraSpacingToLineSpacing/paraSpacingToPoints do the schema-facing conversion -- this module stays format-level, with no document-schema.js knowledge of its own. */ + readonly lineSpacing: number | undefined; + readonly spaceBefore: number | undefined; + readonly spaceAfter: number | undefined; + /** MarginOrIndent ([MS-PPT]): a signed offset in master units, no percentage form -- leftMargin is the paragraph's own left margin, indent the first line's own offset relative to it (negative for a hanging/bullet indent), the identical relationship DrawingML's later marL/indent pair states for the same binary predecessor format. */ + readonly leftMargin: number | undefined; + readonly indent: number | undefined; } export interface CharacterProperties { @@ -193,17 +200,15 @@ function readTextPFException( cursor.skip(4); } const alignment = (masks & PF_ALIGN) !== 0 ? cursor.u16() : undefined; - for (const mask of [ - PF_LINE_SPACING, - PF_SPACE_BEFORE, - PF_SPACE_AFTER, - PF_LEFT_MARGIN, - PF_INDENT, - PF_DEFAULT_TAB_SIZE, - ]) { - if ((masks & mask) !== 0) { - cursor.skip(2); - } + const lineSpacing = + (masks & PF_LINE_SPACING) !== 0 ? cursor.i16() : undefined; + const spaceBefore = + (masks & PF_SPACE_BEFORE) !== 0 ? cursor.i16() : undefined; + const spaceAfter = (masks & PF_SPACE_AFTER) !== 0 ? cursor.i16() : undefined; + const leftMargin = (masks & PF_LEFT_MARGIN) !== 0 ? cursor.i16() : undefined; + const indent = (masks & PF_INDENT) !== 0 ? cursor.i16() : undefined; + if ((masks & PF_DEFAULT_TAB_SIZE) !== 0) { + cursor.skip(2); } if ((masks & PF_TAB_STOPS) !== 0) { // TabStops is a 2-byte count followed by count * 4 bytes ([MS-PPT] 2.9.x), the one variable-length field in the structure and so the only one whose mis-sizing desynchronises every following run. @@ -218,7 +223,15 @@ function readTextPFException( if ((masks & PF_TEXT_DIRECTION) !== 0) { cursor.skip(2); } - return { indentLevel, alignment }; + return { + indentLevel, + alignment, + lineSpacing, + spaceBefore, + spaceAfter, + leftMargin, + indent, + }; } // A mask bit gates whether its property is stated at all, and the corresponding CFStyle bit gives the value. A property whose mask bit is clear stays undefined rather than becoming false: the run simply says nothing about it, and the difference matters because an unstated property inherits from the master's text style rather than defaulting off. From 8893c77fa3a074443ff5a6f9e914c5e657c81e3d Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Fri, 4 Sep 2026 14:22:41 +0100 Subject: [PATCH 2/3] test(ppt-codec): cover paragraph spacing and margins, fixing paragraph-property fixtures Adds coverage for both ParaSpacing forms (percentage-of-line-height and absolute master-units) on lineSpacing/spaceBefore/spaceAfter, for MarginOrIndent conversion including a hanging indent, and a whole-file writePptContent/readPptContent round trip carrying all five new fields together. Widening ParagraphProperties left every hand-built paragraph-run fixture across content.test.ts and text/style-write.test.ts missing the five new fields; extracted a shared pfProps() helper in each file rather than repeating the same five undefined fields at every call site. --- packages/ppt-codec/src/content-write.test.ts | 53 +++++++++ packages/ppt-codec/src/content.test.ts | 111 ++++++++++++++++-- .../ppt-codec/src/text/style-write.test.ts | 40 +++---- packages/ppt-codec/src/write.test.ts | 43 +++++++ 4 files changed, 219 insertions(+), 28 deletions(-) diff --git a/packages/ppt-codec/src/content-write.test.ts b/packages/ppt-codec/src/content-write.test.ts index 7fe84ddef..c0ffd7c77 100644 --- a/packages/ppt-codec/src/content-write.test.ts +++ b/packages/ppt-codec/src/content-write.test.ts @@ -129,6 +129,59 @@ describe("buildTextBody", () => { .alignment, ).toBe(ALIGN_LEFT); }); + + it("converts the schema's line-height multiplier into ParaSpacing's percentage form", () => { + const blocks: ContentBlock[] = [ + { kind: "paragraph", runs: [{ text: "x" }], lineSpacing: 1.5 }, + ]; + expect( + buildTextBody(blocks, noFonts).style.paragraphRuns[0]?.properties + .lineSpacing, + ).toBe(150); + }); + + it("converts spacingBeforePt/spacingAfterPt into ParaSpacing's negative absolute-master-units form", () => { + const blocks: ContentBlock[] = [ + { + kind: "paragraph", + runs: [{ text: "x" }], + spacingBeforePt: 10, + spacingAfterPt: 5, + }, + ]; + const { properties } = buildTextBody(blocks, noFonts).style + .paragraphRuns[0] ?? { properties: undefined }; + expect(properties?.spaceBefore).toBe(-80); + expect(properties?.spaceAfter).toBe(-40); + }); + + it("converts indentLeftPt/indentFirstLinePt into MarginOrIndent master units, including a hanging indent", () => { + const blocks: ContentBlock[] = [ + { + kind: "paragraph", + runs: [{ text: "x" }], + indentLeftPt: 36, + indentFirstLinePt: -18, + }, + ]; + const { properties } = buildTextBody(blocks, noFonts).style + .paragraphRuns[0] ?? { properties: undefined }; + expect(properties?.leftMargin).toBe(288); + expect(properties?.indent).toBe(-144); + }); + + it("leaves lineSpacing/spaceBefore/spaceAfter/leftMargin/indent undefined when a paragraph states none", () => { + const blocks: ContentBlock[] = [ + { kind: "paragraph", runs: [{ text: "x" }] }, + ]; + const { properties } = buildTextBody(blocks, noFonts).style + .paragraphRuns[0] ?? { properties: undefined }; + expect(properties?.lineSpacing).toBeUndefined(); + expect(properties?.spaceBefore).toBeUndefined(); + expect(properties?.spaceAfter).toBeUndefined(); + expect(properties?.leftMargin).toBeUndefined(); + expect(properties?.indent).toBeUndefined(); + }); }); describe("collectFontFamilies", () => { diff --git a/packages/ppt-codec/src/content.test.ts b/packages/ppt-codec/src/content.test.ts index d4d6088e8..4c0c68e38 100644 --- a/packages/ppt-codec/src/content.test.ts +++ b/packages/ppt-codec/src/content.test.ts @@ -16,6 +16,18 @@ function styleOf( return { paragraphRuns, characterRuns }; } +function pfProps(indentLevel: number, alignment: number | undefined) { + return { + indentLevel, + alignment, + lineSpacing: undefined, + spaceBefore: undefined, + spaceAfter: undefined, + leftMargin: undefined, + indent: undefined, + }; +} + describe("buildParagraphs", () => { it("makes one paragraph per carriage-return-separated segment, each a single run when unstyled", () => { expect(buildParagraphs("one\rtwo", NO_STYLE, [])).toEqual([ @@ -32,7 +44,7 @@ describe("buildParagraphs", () => { it("splits a paragraph into the character runs covering it", () => { const style = styleOf( - [{ count: 12, properties: { indentLevel: 0, alignment: undefined } }], + [{ count: 12, properties: pfProps(0, undefined) }], [ { count: 6, @@ -71,7 +83,7 @@ describe("buildParagraphs", () => { it("keeps each paragraph's own slice of a run that spans a paragraph break", () => { // One character run covering the whole body, including the separator, must still produce a run per paragraph. const style = styleOf( - [{ count: 8, properties: { indentLevel: 0, alignment: undefined } }], + [{ count: 8, properties: pfProps(0, undefined) }], [ { count: 8, @@ -99,11 +111,11 @@ describe("buildParagraphs", () => { [ { count: 4, - properties: { indentLevel: 0, alignment: ALIGN_CENTER }, + properties: pfProps(0, ALIGN_CENTER), }, { count: 4, - properties: { indentLevel: 2, alignment: ALIGN_JUSTIFY }, + properties: pfProps(2, ALIGN_JUSTIFY), }, ], [], @@ -120,7 +132,7 @@ describe("buildParagraphs", () => { [ { count: 4, - properties: { indentLevel: 0, alignment: ALIGN_DISTRIBUTED }, + properties: pfProps(0, ALIGN_DISTRIBUTED), }, ], [], @@ -128,9 +140,92 @@ describe("buildParagraphs", () => { expect(buildParagraphs("abc", style, [])[0]?.alignment).toBeUndefined(); }); + it("converts a percentage-form ParaSpacing into the schema's line-height multiplier", () => { + const style = styleOf( + [ + { + count: 3, + properties: { ...pfProps(0, undefined), lineSpacing: 150 }, + }, + ], + [], + ); + expect(buildParagraphs("abc", style, [])[0]?.lineSpacing).toBe(1.5); + }); + + it("leaves lineSpacing undefined for an absolute-master-units ParaSpacing value", () => { + const style = styleOf( + [ + { + count: 3, + properties: { ...pfProps(0, undefined), lineSpacing: -160 }, + }, + ], + [], + ); + expect(buildParagraphs("abc", style, [])[0]?.lineSpacing).toBeUndefined(); + }); + + it("converts an absolute-master-units ParaSpacing into spacingBeforePt/spacingAfterPt", () => { + const style = styleOf( + [ + { + count: 3, + properties: { + ...pfProps(0, undefined), + spaceBefore: -80, + spaceAfter: -40, + }, + }, + ], + [], + ); + const paragraph = buildParagraphs("abc", style, [])[0]; + expect(paragraph?.spacingBeforePt).toBe(10); + expect(paragraph?.spacingAfterPt).toBe(5); + }); + + it("leaves spacingBeforePt/spacingAfterPt undefined for a percentage-form ParaSpacing value", () => { + const style = styleOf( + [ + { + count: 3, + properties: { + ...pfProps(0, undefined), + spaceBefore: 200, + spaceAfter: 0, + }, + }, + ], + [], + ); + const paragraph = buildParagraphs("abc", style, [])[0]; + expect(paragraph?.spacingBeforePt).toBeUndefined(); + expect(paragraph?.spacingAfterPt).toBeUndefined(); + }); + + it("converts MarginOrIndent master units into indentLeftPt/indentFirstLinePt, including a hanging indent", () => { + const style = styleOf( + [ + { + count: 3, + properties: { + ...pfProps(0, undefined), + leftMargin: 288, + indent: -144, + }, + }, + ], + [], + ); + const paragraph = buildParagraphs("abc", style, [])[0]; + expect(paragraph?.indentLeftPt).toBe(36); + expect(paragraph?.indentFirstLinePt).toBe(-18); + }); + it("resolves a run's font reference against the document's font collection", () => { const style = styleOf( - [{ count: 4, properties: { indentLevel: 0, alignment: undefined } }], + [{ count: 4, properties: pfProps(0, undefined) }], [ { count: 4, @@ -161,7 +256,7 @@ describe("buildParagraphs", () => { it("leaves the font family absent when the reference names no entry in the collection", () => { const style = styleOf( - [{ count: 4, properties: { indentLevel: 0, alignment: undefined } }], + [{ count: 4, properties: pfProps(0, undefined) }], [ { count: 4, @@ -186,7 +281,7 @@ describe("buildParagraphs", () => { it("falls back to one unformatted run per paragraph when the runs do not reach it", () => { // A style atom covering only the first characters leaves later paragraphs with no run of their own; they still need their text. const style = styleOf( - [{ count: 2, properties: { indentLevel: 0, alignment: undefined } }], + [{ count: 2, properties: pfProps(0, undefined) }], [ { count: 2, diff --git a/packages/ppt-codec/src/text/style-write.test.ts b/packages/ppt-codec/src/text/style-write.test.ts index 5e1b7315b..447b2cc52 100644 --- a/packages/ppt-codec/src/text/style-write.test.ts +++ b/packages/ppt-codec/src/text/style-write.test.ts @@ -31,12 +31,22 @@ function noCharacterProperties() { }; } +function pfProps(indentLevel: number, alignment: number | undefined) { + return { + indentLevel, + alignment, + lineSpacing: undefined, + spaceBefore: undefined, + spaceAfter: undefined, + leftMargin: undefined, + indent: undefined, + }; +} + describe("writeStyleTextPropAtom", () => { it("round-trips a paragraph run's alignment", () => { const style: StyleTextProps = { - paragraphRuns: [ - { count: 5, properties: { indentLevel: 0, alignment: ALIGN_CENTER } }, - ], + paragraphRuns: [{ count: 5, properties: pfProps(0, ALIGN_CENTER) }], characterRuns: [{ count: 5, properties: noCharacterProperties() }], }; expect(roundTrip(style, 5).paragraphRuns).toEqual(style.paragraphRuns); @@ -44,9 +54,7 @@ describe("writeStyleTextPropAtom", () => { it("round-trips a paragraph run stating no alignment at all", () => { const style: StyleTextProps = { - paragraphRuns: [ - { count: 5, properties: { indentLevel: 2, alignment: undefined } }, - ], + paragraphRuns: [{ count: 5, properties: pfProps(2, undefined) }], characterRuns: [{ count: 5, properties: noCharacterProperties() }], }; expect(roundTrip(style, 5).paragraphRuns).toEqual(style.paragraphRuns); @@ -55,8 +63,8 @@ describe("writeStyleTextPropAtom", () => { it("round-trips several paragraph runs covering the whole character count", () => { const style: StyleTextProps = { paragraphRuns: [ - { count: 3, properties: { indentLevel: 0, alignment: undefined } }, - { count: 4, properties: { indentLevel: 1, alignment: undefined } }, + { count: 3, properties: pfProps(0, undefined) }, + { count: 4, properties: pfProps(1, undefined) }, ], characterRuns: [{ count: 7, properties: noCharacterProperties() }], }; @@ -65,9 +73,7 @@ describe("writeStyleTextPropAtom", () => { it("round-trips a character run's bold/italic/underline flags", () => { const style: StyleTextProps = { - paragraphRuns: [ - { count: 4, properties: { indentLevel: 0, alignment: undefined } }, - ], + paragraphRuns: [{ count: 4, properties: pfProps(0, undefined) }], characterRuns: [ { count: 4, @@ -89,9 +95,7 @@ describe("writeStyleTextPropAtom", () => { it("round-trips a character run stating no font-style flags at all", () => { const style: StyleTextProps = { - paragraphRuns: [ - { count: 3, properties: { indentLevel: 0, alignment: undefined } }, - ], + paragraphRuns: [{ count: 3, properties: pfProps(0, undefined) }], characterRuns: [ { count: 3, @@ -113,9 +117,7 @@ describe("writeStyleTextPropAtom", () => { it("round-trips a character run's font reference, size, and literal colour", () => { const style: StyleTextProps = { - paragraphRuns: [ - { count: 3, properties: { indentLevel: 0, alignment: undefined } }, - ], + paragraphRuns: [{ count: 3, properties: pfProps(0, undefined) }], characterRuns: [ { count: 3, @@ -137,9 +139,7 @@ describe("writeStyleTextPropAtom", () => { it("round-trips several character runs covering the whole character count", () => { const style: StyleTextProps = { - paragraphRuns: [ - { count: 6, properties: { indentLevel: 0, alignment: undefined } }, - ], + paragraphRuns: [{ count: 6, properties: pfProps(0, undefined) }], characterRuns: [ { count: 3, diff --git a/packages/ppt-codec/src/write.test.ts b/packages/ppt-codec/src/write.test.ts index aedd476e5..ff0043f70 100644 --- a/packages/ppt-codec/src/write.test.ts +++ b/packages/ppt-codec/src/write.test.ts @@ -218,6 +218,49 @@ describe("writePptContent / readPptContent round trip", () => { ]); }); + it("round-trips a paragraph's line spacing, before/after spacing, and left margin/indent", () => { + const document = { + metadata: {}, + slides: [ + slide({ + shapes: [ + { + frame: { xPt: 0, yPt: 0, widthPt: 100, heightPt: 100 }, + insetLeftPt: 0, + insetTopPt: 0, + insetRightPt: 0, + insetBottomPt: 0, + blocks: [ + { + kind: "paragraph" as const, + runs: [{ text: "Spaced" }], + lineSpacing: 1.5, + spacingBeforePt: 10, + spacingAfterPt: 5, + indentLeftPt: 36, + indentFirstLinePt: -18, + }, + ], + }, + ], + }), + ], + }; + + const { slides } = readPptContent(writePptContent(document)); + expect(slides[0]?.shapes[0]?.blocks).toEqual([ + { + kind: "paragraph", + runs: [{ text: "Spaced" }], + lineSpacing: 1.5, + spacingBeforePt: 10, + spacingAfterPt: 5, + indentLeftPt: 36, + indentFirstLinePt: -18, + }, + ]); + }); + it("round-trips several character-formatted runs within one paragraph", () => { const document = { metadata: {}, From 99837309039e60c6591296487efe7e40c6a6bc8d Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Fri, 4 Sep 2026 14:22:50 +0100 Subject: [PATCH 3/3] docs(ppt-codec): document paragraph spacing and margins as read and written Moves lineSpacing/spaceBefore/spaceAfter/leftMargin/indent from the read/write gap lists into the reader/writer's own formatting rows, notes which ParaSpacing form each schema field can and cannot express, and records LibreOffice verification for line spacing, paragraph spacing, and left margin. LibreOffice's own .ppt import does not recover indentFirstLinePt: this package's independent, specification-only reader recovers the written value exactly, and the field's byte order was separately confirmed against the MS-PPT TextPFException specification, so the discrepancy is noted as LibreOffice's own import behaviour rather than left unstated. --- packages/ppt-codec/README.md | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/packages/ppt-codec/README.md b/packages/ppt-codec/README.md index cd4de627e..390809eff 100644 --- a/packages/ppt-codec/README.md +++ b/packages/ppt-codec/README.md @@ -79,17 +79,17 @@ const bytes = writePptContent({ metadata: {}, slides }); The whole path from a file's first byte to a slide's text, record by record: -| Layer | Records | -| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Container | The `Current User` and `PowerPoint Document` streams, read through `archive-codec`'s bounded [MS-CFB] reader. | -| Record framing | The generic 8-byte `RecordHeader`, the container/atom distinction, sibling sequences, child walks, and typed-descendant search — shared with [MS-ODRAW]'s records, which carry the identical header. | -| Edit resolution | `CurrentUserAtom` (including its encrypted/plaintext `headerToken`), the `UserEditAtom` chain, `PersistDirectoryAtom`/`PersistDirectoryEntry`'s packed 20-bit/12-bit run form, and the oldest-first directory construction whose later entries supersede earlier ones — [MS-PPT] 2.1.2's own "live record" process, Part 1. | -| Document | `DocumentContainer` → `DocumentAtom` (slide size, in master units), `DocumentTextInfoContainer`'s `FontCollectionContainer`/`FontEntityAtom` typeface names, and `SlideListWithTextContainer` (distinguished from the master and notes lists by `recInstance`, which does not run in the order the names suggest). | -| Slides | `SlidePersistAtom` → the persist directory → each `SlideContainer`, and the placeholder texts the slide list carries for it. | -| Speaker notes | `NotesListWithTextContainer` (the third of the three containers sharing `RT_SlideListWithText`) → `NotesPersistAtom` → the persist directory → each `NotesContainer`, and the `NotesAtom.slideIdRef` naming the presentation slide those notes belong to. The text comes from the notes slide's own drawing, since the notes list — unlike the slide list — carries no texts for an `OutlineTextRefAtom` to reach into. | -| Drawing | `DrawingContainer` → `OfficeArtDgContainer` → the `OfficeArtSpgrContainer`/`OfficeArtSpContainer` tree, `OfficeArtFSP`'s group/patriarch/deleted flags, `OfficeArtClientAnchor` in both its 8-byte `SmallRectStruct` and 16-byte `RectStruct` spellings, and `OfficeArtChildAnchor` mapped through nested `OfficeArtFSPGR` group coordinate systems. | -| Text | `OfficeArtClientTextbox`, `TextHeaderAtom`, `TextCharsAtom` (UTF-16) and `TextBytesAtom` (one byte per character), `OutlineTextRefAtom` indirection into the slide list, and the paragraph split on the stored `\r`. | -| Formatting | `StyleTextPropAtom`: `TextPFRun`/`TextPFException` (indent level, alignment) and `TextCFRun`/`TextCFException` (bold, italic, underline, shadow, emboss, typeface reference, size in points, and a `ColorIndexStruct` colour when it is a literal sRGB value), each read in the spec's **declared field order** rather than its mask-bit order — the two differ, and following the mask-bit order desynchronises every field after the first divergence. | +| Layer | Records | +| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Container | The `Current User` and `PowerPoint Document` streams, read through `archive-codec`'s bounded [MS-CFB] reader. | +| Record framing | The generic 8-byte `RecordHeader`, the container/atom distinction, sibling sequences, child walks, and typed-descendant search — shared with [MS-ODRAW]'s records, which carry the identical header. | +| Edit resolution | `CurrentUserAtom` (including its encrypted/plaintext `headerToken`), the `UserEditAtom` chain, `PersistDirectoryAtom`/`PersistDirectoryEntry`'s packed 20-bit/12-bit run form, and the oldest-first directory construction whose later entries supersede earlier ones — [MS-PPT] 2.1.2's own "live record" process, Part 1. | +| Document | `DocumentContainer` → `DocumentAtom` (slide size, in master units), `DocumentTextInfoContainer`'s `FontCollectionContainer`/`FontEntityAtom` typeface names, and `SlideListWithTextContainer` (distinguished from the master and notes lists by `recInstance`, which does not run in the order the names suggest). | +| Slides | `SlidePersistAtom` → the persist directory → each `SlideContainer`, and the placeholder texts the slide list carries for it. | +| Speaker notes | `NotesListWithTextContainer` (the third of the three containers sharing `RT_SlideListWithText`) → `NotesPersistAtom` → the persist directory → each `NotesContainer`, and the `NotesAtom.slideIdRef` naming the presentation slide those notes belong to. The text comes from the notes slide's own drawing, since the notes list — unlike the slide list — carries no texts for an `OutlineTextRefAtom` to reach into. | +| Drawing | `DrawingContainer` → `OfficeArtDgContainer` → the `OfficeArtSpgrContainer`/`OfficeArtSpContainer` tree, `OfficeArtFSP`'s group/patriarch/deleted flags, `OfficeArtClientAnchor` in both its 8-byte `SmallRectStruct` and 16-byte `RectStruct` spellings, and `OfficeArtChildAnchor` mapped through nested `OfficeArtFSPGR` group coordinate systems. | +| Text | `OfficeArtClientTextbox`, `TextHeaderAtom`, `TextCharsAtom` (UTF-16) and `TextBytesAtom` (one byte per character), `OutlineTextRefAtom` indirection into the slide list, and the paragraph split on the stored `\r`. | +| Formatting | `StyleTextPropAtom`: `TextPFRun`/`TextPFException` (indent level, alignment, line spacing, space before/after, left margin, and first-line indent) and `TextCFRun`/`TextCFException` (bold, italic, underline, shadow, emboss, typeface reference, size in points, and a `ColorIndexStruct` colour when it is a literal sRGB value), each read in the spec's **declared field order** rather than its mask-bit order — the two differ, and following the mask-bit order desynchronises every field after the first divergence. | Geometry is converted from master units (1/576 inch) to points on the way out, so a slide's `size` and every shape's `frame` are in the same unit the shared schema uses everywhere else. @@ -104,9 +104,10 @@ Each of these is a real construct of the format that this package currently igno - **Per-shape text insets.** Every shape reports PowerPoint's own defaults (0.1 inch left and right, 0.05 inch top and bottom); a per-shape override lives in the shape's `OfficeArtFOPT` property table, which is not read. - **Images, tables, and OLE embeddings.** A picture shape, a table object, and an embedded or linked OLE object all read as a shape with geometry and no blocks. `ExObjListContainer` and the `ExOleObjStg` persist objects are not walked. - **Shapes with no anchor.** A shape carrying neither an `OfficeArtClientAnchor` nor an `OfficeArtChildAnchor` is dropped, because `ContentShape` has no way to say "positioned, but unknown where". -- **Hyperlinks, bullets, spacing and margins.** `InteractiveInfo`/`TextInteractiveInfoAtom`, `TextPFException`'s bullet fields, and its `lineSpacing`/`spaceBefore`/`spaceAfter`/`leftMargin`/`indent` are parsed past correctly but not surfaced. +- **Hyperlinks and bullets.** `InteractiveInfo`/`TextInteractiveInfoAtom` and `TextPFException`'s bullet fields are parsed past correctly but not surfaced. - **Animations, transitions, comments, headers and footers, and the metacharacter atoms** (slide number, date, header, footer). - **Alignment values the shared schema has no name for.** `Tx_ALIGNDistributed`, `Tx_ALIGNThaiDistributed` and `Tx_ALIGNJustifyLow` map to no alignment rather than being rounded to `justify`. +- **`ParaSpacing` values in the form the shared schema cannot state.** `lineSpacing` (the schema's line-height multiplier) only has a value to report for `ParaSpacing`'s percentage-of-line-height form; `spacingBeforePt`/`spacingAfterPt` (the schema's plain points) only have a value for the absolute master-units form. A paragraph stating the other form of either field reports no value at all for it, rather than a wrong one — there is no rendered line height available here to convert one form into the other. - **The soft line break.** U+000B inside a paragraph is converted to a newline, an inference from the spec's own worked examples rather than a rule it states; the specification publishes no table of the special characters a text body may hold. ## What it writes @@ -124,7 +125,7 @@ The whole path from a `ContentSlide[]` to a real `.ppt` file's bytes, mirroring | Speaker notes | One `NotesContainer` per slide that actually has notes — a `NotesAtom` naming that slide, then a `DrawingContainer` whose single text box carries the notes, one paragraph per line, then the `SlideSchemeColorSchemeAtom` [MS-PPT] 2.5.6 requires of one — the notes slide's own `NotesAtom.slideFlags` leaves `fMasterScheme` clear, so it inherits no scheme and has to state one. A slide with no notes gets no notes slide at all rather than an empty one. | | Drawing | `OfficeArtDgContainer` → one `OfficeArtSpgrContainer` (the patriarch group every real drawing carries) → one plain `OfficeArtSpContainer` per shape, each anchored in slide coordinates via a 32-bit `OfficeArtClientAnchor` (`RectStruct`, never the 16-bit `SmallRectStruct`) — no grouping, no `OfficeArtChildAnchor` nesting. | | Text | Every shape carries its own text directly on its `OfficeArtClientTextbox` (`TextHeaderAtom` + a UTF-16 `TextCharsAtom`) rather than through the `OutlineTextRefAtom` placeholder indirection into the slide list — a plain text box is all this writer produces, so there is no separate placeholder text to route through the document's own slide list. | -| Formatting | `StyleTextPropAtom`: one `TextPFRun` per paragraph (indent level, alignment) and one `TextCFRun` per character run (bold, italic, underline, a font-collection reference, size in points, and a literal sRGB `ColorIndexStruct` colour), fields written in the identical spec-declared order `readTextPFException`/`readTextCFException` parse them in. | +| Formatting | `StyleTextPropAtom`: one `TextPFRun` per paragraph (indent level, alignment, line spacing, space before/after, left margin, and first-line indent) and one `TextCFRun` per character run (bold, italic, underline, a font-collection reference, size in points, and a literal sRGB `ColorIndexStruct` colour), fields written in the identical spec-declared order `readTextPFException`/`readTextCFException` parse them in. | Geometry is converted from points to master units on the way in, rounding to the nearest whole master unit (1/576 inch) — the format's own smallest unit of length. @@ -144,6 +145,7 @@ The round trip above proves the reader and writer agree with each other. Speaker - **Reading real bytes.** A presentation authored as flat ODF and converted with `soffice --headless --convert-to ppt` — three slides, notes on the first and third, none on the second — is read by `readPptContent`, and the recovered notes match what LibreOffice's own `--convert-to fodp` re-export of the same file independently reports. This is what established that a real producer stores the notes body on a plain, un-placeholdered text box whose `TextHeaderAtom` states `Tx_TYPE_OTHER`, rather than on the `PT_NotesBody` placeholder the spelling suggests — a reader keyed on the notes text type would recover nothing from a real file. - **Writing bytes a real consumer reads.** A `.ppt` written by `writePptContent` with notes on some slides opens in LibreOffice with every slide's own text intact and each slide's notes inside `presentation:notes` — the notes view — rather than on the slide itself, confirmed by converting the written file back with `--convert-to fodp` and checking which element the text landed in. That last check is the one that matters: the same class of bug (notes rendering on the slide rather than the notes page) was caught in `odf.js`'s own `writeOdp` by exactly this test and by nothing else. Feeding the written file back through LibreOffice's own PPT export and reading _that_ returns the same slides and the same notes again. +- **Line spacing, paragraph spacing, and left margin.** A `.ppt` written with `lineSpacing`, `spacingBeforePt`/`spacingAfterPt`, and `indentLeftPt` set converts cleanly through LibreOffice to both `.pptx` (`a:lnSpc`/`a:spcBef`/`a:spcAft`/`a:pPr@marL`) and `.odp` (`fo:line-height`/`fo:margin-top`/`fo:margin-bottom`/`fo:margin-left`), each matching the written value. `indentFirstLinePt` (the hanging/first-line indent, `TextPFException.indent`) does not: on every input tried, regardless of export target, LibreOffice's own import produces a value with no relationship to what was written. This package's own reader — built independently against the specification alone, with no knowledge of the writer's internals — recovers the exact value written, and the field's byte offset and order were separately confirmed against the [MS-PPT] `TextPFException` specification directly (`leftMargin` then `indent`, both after `spaceAfter`), so the discrepancy sits in LibreOffice's own import of this one field rather than in the bytes offered to it. ## What it does not write yet @@ -155,7 +157,7 @@ Each of these is either a real construct this writer deliberately does not attem - **Per-shape text insets, autofit, and paint order.** `ContentShape.insetLeftPt`/`insetTopPt`/`insetRightPt`/`insetBottomPt`, `fontScale`, `lineSpacingReduction`, and `paintOrder` have no `OfficeArtFOPT` property table to land in, since this writer does not build one. - **Master content, layouts, and scheme colours.** A `MainMasterContainer` and its `MasterListWithTextContainer` are written, but only as the minimum [MS-PPT] requires of one (see [Why a writer of plain text-box slides writes a master slide](#why-a-writer-of-plain-text-box-slides-writes-a-master-slide)): its five placeholder shapes carry no text, its `TextMasterStyleAtom` items state no style level of their own, and its `SlideSchemeColorSchemeAtom` is a fixed default rather than anything the input chose. There are still no slide layouts, and every character run's colour must already be a literal, since no scheme is there to resolve one against. - **Notes masters, and a notes page geometry of its own.** No `NotesContainer` is written for the notes master, and `DocumentAtom.notesMasterPersistIdRef` stays 0, so each notes slide inherits nothing (its `NotesAtom.slideFlags` is clear) and states the same fixed default `SlideSchemeColorSchemeAtom` the master does rather than a scheme of the input's choosing. The notes page is the same size as the slide, because `ContentSlide` carries no notes-page geometry to state a different one from, and the notes text box is placed in the lower half of it. -- **Hyperlinks, bullets, spacing, margins, and list numbering identity.** `ContentRun.hyperlink`, `ContentParagraph.list.numId`/`checked`/`itemId`, `spacingBeforePt`/`spacingAfterPt`/`lineSpacing`/`indentLeftPt`/`indentFirstLinePt`, and `pageBreakBefore`/`pageBreakAfter` have no [MS-PPT] field this writer populates; only `alignment` and `list.level` (as a `TextPFException` indent level) round-trip. +- **Hyperlinks, bullets, and list numbering identity.** `ContentRun.hyperlink`, `ContentParagraph.list.numId`/`checked`/`itemId`, and `pageBreakBefore`/`pageBreakAfter` have no [MS-PPT] field this writer populates; `alignment`, `list.level` (as a `TextPFException` indent level), `spacingBeforePt`/`spacingAfterPt`/`lineSpacing`/`indentLeftPt`/`indentFirstLinePt` round-trip. - **`strike`, `sourcePath`, `source`, and `frames`.** `ContentRun.strike` has no `TextCFException` bit this writer sets (the format's own `CFMasks`/`CFStyle` carry no strikethrough bit at all — a real gap in [MS-PPT], not a scope choice); the three fidelity/positioning fields are round-trip-irrelevant to a fresh write and are never populated. - **Construct markers.** A `constructStart`/`constructEnd` pair (or any other non-`paragraph` block kind) is excluded from the written text body exactly like an image or table block, per [Writing a document](#writing-a-document). - **Alignment values the shared schema has no name for.** The mirror of the read-side gap: `Tx_ALIGNDistributed`, `Tx_ALIGNThaiDistributed`, and `Tx_ALIGNJustifyLow` are never written, since `Alignment` has no member naming them.