Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 16 additions & 14 deletions packages/ppt-codec/README.md

Large diffs are not rendered by default.

53 changes: 53 additions & 0 deletions packages/ppt-codec/src/content-write.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
24 changes: 24 additions & 0 deletions packages/ppt-codec/src/content-write.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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;
Expand Down Expand Up @@ -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),
},
});

Expand Down
111 changes: 103 additions & 8 deletions packages/ppt-codec/src/content.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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),
},
],
[],
Expand All @@ -120,17 +132,100 @@ describe("buildParagraphs", () => {
[
{
count: 4,
properties: { indentLevel: 0, alignment: ALIGN_DISTRIBUTED },
properties: pfProps(0, ALIGN_DISTRIBUTED),
},
],
[],
);
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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
27 changes: 27 additions & 0 deletions packages/ppt-codec/src/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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;
Expand Down Expand Up @@ -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),
};
});
}
Loading