From d071f9d90679f4112014276beaabad078eb92782 Mon Sep 17 00:00:00 2001 From: fcbwilliams Date: Thu, 10 Sep 2026 15:11:35 +0200 Subject: [PATCH 1/2] feat(ooxml.js): add an opt-in reading-order projection for pptx slides `readPptxContent` returns `ContentSlide.shapes` in `p:spTree` order, which is z-order -- roughly creation order -- and bears no relation to layout. That is fine for a consumer rendering the shapes, since each carries its own frame and is positioned independently. It stops being fine the moment a consumer reads a slide as prose: concatenating `shapes` in array order puts a column of bullets ahead of the heading that owns them, which the same deck exported to PDF does not do (a PDF renderer has already resolved layout to reading order). `orderShapesForReading` recovers that order by recursive XY-cut over the frames already on every shape: find a band of empty space no shape straddles, take the groups either side in order, recurse, and fall back to topmost-then-leftmost where a set overlaps on both axes. The axis is chosen per cut rather than always cutting rows first, which is what keeps a two-column slide readable -- and the gap is compared *relative* to the extent the shapes occupy on each axis, because a 16:9 slide is twice as wide as it is tall, so an absolute comparison reads a four-box grid down its columns instead of across its rows. Both layouts are covered by tests. Deliberately NOT applied inside `readPptxContent`. `ContentShape`'s `sourcePath` is assigned during the shape-tree walk as `slides[N].shapes[N]`, and the existing sourcePath tests assert it matches the shape's own array position -- so reordering the array in place would either desynchronise those paths from the positions they name, or redefine sourcePath away from the document order its comment promises. Exported instead, so a consumer reading a slide as prose can sort while one correlating by sourcePath keeps the order it has. Happy to make it the default and reassign sourcePath afterwards if you would rather -- that is your call about what sourcePath means, which is why this PR does not make it. No behaviour change for any existing consumer. --- packages/ooxml.js/src/index.ts | 6 + .../src/typed/pptx/reading-order.test.ts | 95 +++++++++++++++ .../ooxml.js/src/typed/pptx/reading-order.ts | 115 ++++++++++++++++++ 3 files changed, 216 insertions(+) create mode 100644 packages/ooxml.js/src/typed/pptx/reading-order.test.ts create mode 100644 packages/ooxml.js/src/typed/pptx/reading-order.ts diff --git a/packages/ooxml.js/src/index.ts b/packages/ooxml.js/src/index.ts index e7104af99..e85a3329f 100644 --- a/packages/ooxml.js/src/index.ts +++ b/packages/ooxml.js/src/index.ts @@ -329,6 +329,12 @@ export type { // --- pptx: a PresentationML reader resolving the placeholder -> layout -> master -> theme inheritance cascade and DrawingML geometry into slides of positioned, styled shapes. The flat half of readPptx above; read-only either way, since this package has no PresentationML writer. --- export { readPptxContent, PptxDocumentSchema } from "./typed/pptx/read"; export type { PptxDocument } from "./typed/pptx/read"; +// Opt-in reading-order projection over a slide's shapes. Deliberately not applied by readPptxContent +// itself: ContentShape.sourcePath is assigned during the shape-tree walk as slides[N].shapes[N], so +// reordering the array in place would either desynchronise those paths from the positions they name or +// redefine them away from document order. Exported so a consumer reading a slide as prose can sort, +// while one correlating by sourcePath keeps the order it has. +export { orderShapesForReading } from "./typed/pptx/reading-order"; // The lossy, cell-values-only xlsx reading view (sheet names, cell references, resolved values, formulas, merged ranges, defined names -- no formats, styles, geometry, or charts), with no write side and no ContentDocument shape. It held the name readXlsx until that name went to the package-native reader above; readXlsxWorkbook says what it returns, exactly as readXlsxContent beside it does. export { diff --git a/packages/ooxml.js/src/typed/pptx/reading-order.test.ts b/packages/ooxml.js/src/typed/pptx/reading-order.test.ts new file mode 100644 index 000000000..433964c71 --- /dev/null +++ b/packages/ooxml.js/src/typed/pptx/reading-order.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; +import type { ContentShape } from "document-schema.js"; +import { orderShapesForReading } from "./reading-order"; + +// A shape carrying only what the ordering looks at: its frame, and a name to assert the order by. +function shape( + name: string, + xPt: number, + yPt: number, + widthPt: number, + heightPt: number, +): ContentShape { + return { + name, + frame: { xPt, yPt, widthPt, heightPt }, + insetLeftPt: 0, + insetTopPt: 0, + insetRightPt: 0, + insetBottomPt: 0, + blocks: [], + }; +} + +const order = (shapes: ContentShape[]): (string | undefined)[] => + orderShapesForReading(shapes).map((s) => s.name); + +describe("orderShapesForReading", () => { + it("reads a two-column slide column by column, keeping each heading with its own list", () => { + // The layout that motivates cutting on an axis rather than always sorting top-to-bottom: sorting by + // y alone interleaves the columns and separates every heading from the bullets it introduces. + const shapes = [ + shape("right-list", 400, 120, 300, 200), + shape("left-heading", 40, 60, 300, 40), + shape("right-heading", 400, 60, 300, 40), + shape("left-list", 40, 120, 300, 200), + ]; + + expect(order(shapes)).toEqual([ + "left-heading", + "left-list", + "right-heading", + "right-list", + ]); + }); + + it("reads a title above a body top-to-bottom, not as columns", () => { + const shapes = [ + shape("body", 40, 140, 660, 300), + shape("title", 40, 40, 660, 60), + ]; + + expect(order(shapes)).toEqual(["title", "body"]); + }); + + it("reads a four-box grid row by row, despite a 16:9 slide's horizontal gaps being larger", () => { + // The case the relative-gap comparison exists for: on a 720x405pt slide the gap between columns is + // physically wider than the gap between rows for the same visual separation, so an absolute + // comparison cuts columns and reads down each one instead of across each row. + const shapes = [ + shape("r1c1", 40, 40, 300, 120), + shape("r1c2", 380, 40, 300, 120), + shape("r2c1", 40, 220, 300, 120), + shape("r2c2", 380, 220, 300, 120), + ]; + + expect(order(shapes)).toEqual(["r1c1", "r1c2", "r2c1", "r2c2"]); + }); + + it("recurses, so a column's own internal rows are ordered within that column", () => { + const shapes = [ + shape("left-bottom", 40, 300, 300, 80), + shape("right", 400, 40, 300, 340), + shape("left-top", 40, 40, 300, 80), + ]; + + expect(order(shapes)).toEqual(["left-top", "left-bottom", "right"]); + }); + + it("falls back to topmost-then-leftmost for shapes that overlap on both axes", () => { + // Neither axis has a band of empty space crossing the whole set, so no cut is possible. A total + // order (y, then x) keeps the result deterministic rather than dependent on input order. + const shapes = [ + shape("lower", 100, 200, 400, 300), + shape("upper", 60, 60, 400, 300), + ]; + + expect(order(shapes)).toEqual(["upper", "lower"]); + expect(order([...shapes].reverse())).toEqual(["upper", "lower"]); + }); + + it("leaves a single shape, or none, alone", () => { + expect(order([])).toEqual([]); + expect(order([shape("only", 10, 10, 10, 10)])).toEqual(["only"]); + }); +}); diff --git a/packages/ooxml.js/src/typed/pptx/reading-order.ts b/packages/ooxml.js/src/typed/pptx/reading-order.ts new file mode 100644 index 000000000..715a32f85 --- /dev/null +++ b/packages/ooxml.js/src/typed/pptx/reading-order.ts @@ -0,0 +1,115 @@ +import type { Box, ContentShape } from "document-schema.js"; + +// Orders a slide's shapes the way a person looking at it would take them, by recursive XY-cut, rather +// than in the order p:spTree happens to list them. +// +// p:spTree order is z-order -- roughly creation order -- and bears no relation to layout: a title box +// drawn last sits last in the file, and a two-column slide interleaves its columns arbitrarily. That is +// survivable for a consumer rendering the shapes, since each carries its own frame and is positioned +// independently. It stops being survivable the moment a consumer reads a slide as prose: concatenating +// ContentSlide.shapes in array order then puts a column of bullets ahead of the heading that owns them, +// which the same deck exported to PDF does not do (a PDF renderer has already resolved layout to reading +// order). The geometry needed to fix it is already on every shape. +// +// Every shape reaching here has a real frame: resolveShapeFrame resolves a placeholder's inherited +// a:xfrm through the layout/master cascade, and a shape whose geometry cannot be resolved at all is +// dropped rather than emitted at a default position. So there is no "geometry missing" case to guard -- +// which is not true of every implementation of this, and is worth knowing when comparing. +export function orderShapesForReading( + shapes: readonly ContentShape[], +): ContentShape[] { + return cut([...shapes]); +} + +type Axis = "vertical" | "horizontal"; + +const start = (frame: Box, axis: Axis): number => + axis === "vertical" ? frame.yPt : frame.xPt; + +const end = (frame: Box, axis: Axis): number => + axis === "vertical" ? frame.yPt + frame.heightPt : frame.xPt + frame.widthPt; + +// One step of the cut: take whichever axis offers the widest band of empty space *relative to how far +// the shapes reach along that axis*, split on it, and recurse into each group. +// +// Choosing an axis at all, rather than always cutting rows first, is what keeps a two-column slide +// readable. Where each column is a heading above its own bullet list, the band between the columns is +// the wider one, so cutting columns yields heading-then-its-list twice; cutting rows would yield both +// headings and then both lists, separating every heading from the list it introduces. On a +// title-above-body slide the same comparison comes out the other way round. +// +// Relative rather than absolute, because the two gaps are measured along different axes and a raw +// comparison silently favours the wider one. A 16:9 slide is roughly twice as wide as it is tall, so +// horizontal gaps start out nearly twice as large for the same visual separation, and a four-box grid -- +// which a reader takes row by row -- cuts into columns instead. Dividing each gap by the extent the +// shapes actually occupy on its own axis removes that bias and settles both layouts correctly. +// +// Ties, including the degenerate case where a set has no extent on an axis, go to rows: the ordinary +// top-to-bottom reading of a slide with no column structure. +function cut(shapes: ContentShape[]): ContentShape[] { + if (shapes.length <= 1) { + return shapes; + } + const rows = splitOnGap(shapes, "vertical"); + const columns = splitOnGap(shapes, "horizontal"); + if ( + ratio(columns.widestGap, extentAlong(shapes, "horizontal")) > + ratio(rows.widestGap, extentAlong(shapes, "vertical")) && + columns.groups.length > 1 + ) { + return columns.groups.flatMap(cut); + } + if (rows.groups.length > 1) { + return rows.groups.flatMap(cut); + } + // Neither axis can be cut, so the shapes overlap: topmost, then leftmost. Deliberately a total order + // (the x tiebreak), so a slide of overlapping shapes is at least ordered deterministically rather than + // left in whatever order the sort happened to leave equal keys in. + return [...shapes].sort( + (a, b) => a.frame.yPt - b.frame.yPt || a.frame.xPt - b.frame.xPt, + ); +} + +// How far a set of shapes reaches along one axis, from the earliest start to the latest end. +function extentAlong(shapes: readonly ContentShape[], axis: Axis): number { + const starts = shapes.map((shape) => start(shape.frame, axis)); + const ends = shapes.map((shape) => end(shape.frame, axis)); + return Math.max(...ends) - Math.min(...starts); +} + +// A gap as a fraction of the extent it sits in; zero when there is no extent to measure it against, so +// such an axis never wins a comparison. +function ratio(gap: number, extent: number): number { + return extent > 0 ? gap / extent : 0; +} + +// Splits shapes wherever a band of space crosses the whole set with nothing in it: "vertical" sweeps down +// the y axis (producing rows, top first), "horizontal" across the x axis (producing columns, left first). +// Reports the widest such band alongside the groups, which is what lets cut choose between the two axes; +// a single group means no band exists and the widest gap is zero. +function splitOnGap( + shapes: readonly ContentShape[], + axis: Axis, +): { groups: ContentShape[][]; widestGap: number } { + const sorted = [...shapes].sort( + (a, b) => start(a.frame, axis) - start(b.frame, axis), + ); + const groups: ContentShape[][] = []; + let current: ContentShape[] = []; + let reach = Number.NEGATIVE_INFINITY; + let widestGap = 0; + + for (const shape of sorted) { + if (current.length > 0 && start(shape.frame, axis) > reach) { + widestGap = Math.max(widestGap, start(shape.frame, axis) - reach); + groups.push(current); + current = []; + } + current.push(shape); + reach = Math.max(reach, end(shape.frame, axis)); + } + if (current.length > 0) { + groups.push(current); + } + return { groups, widestGap }; +} From ce6b282796afd06d28fa6286a14bff354805c974 Mon Sep 17 00:00:00 2001 From: fcbwilliams Date: Thu, 10 Sep 2026 17:20:04 +0200 Subject: [PATCH 2/2] feat(ooxml.js): record a pptx slide's reading order as a rank per shape `readPptxContent` returns `ContentSlide.shapes` in `p:spTree` order, which is z-order -- roughly creation order -- and bears no relation to layout. That is fine for a consumer rendering the shapes, since each carries its own frame. It stops being fine for one reading a slide as prose: in spTree order a column of bullets can precede the heading that owns them, which the same deck exported to PDF does not do, because a PDF renderer has already resolved layout to reading order. `ContentShape` gains an optional `readingOrder`, recovered from the shapes' own geometry by recursive XY-cut: find a band of empty space no shape straddles, take the groups either side in order, recurse, and fall back to topmost-then-leftmost where a set overlaps on both axes. **A rank on the shape, not a reordered array**, expressed exactly as `paintOrder` already is -- including the same plain `z.number()`, for the same reason its comment gives: a fractional value can be inserted between two existing ones later. `sourcePath` is assigned as `slides[N].shapes[N]` and has to keep naming the position it names, so sorting the array would either desynchronise every path or redefine sourcePath away from the document order its own comment promises. The array is returned untouched; a consumer wanting reading order sorts by the rank, and one that does not is unaffected. Two details that took measuring, both covered by tests: The axis is chosen per cut rather than always cutting rows first, which is what keeps a two-column slide readable -- where each column is a heading above its own list, cutting rows yields both headings then both lists. On a title-above-body slide the same comparison comes out the other way round. The gap is compared *relative* to the extent the shapes occupy on each axis, because a 16:9 slide is nearly twice as wide as it is tall: an absolute comparison reads a four-box grid down its columns instead of across its rows. Additive and optional throughout, so no existing consumer changes behaviour. `ShapeDescriptor`'s hand-authored JSON Schema fragment is updated to match; the `ContentVector` variants deliberately are not, since they carry `paintOrder` but have no reading order of their own. --- .../src/content-json-schema-defs.ts | 1 + packages/document-schema.js/src/content.ts | 1 + packages/ooxml.js/src/index.ts | 6 ---- packages/ooxml.js/src/typed/pptx/read.ts | 7 +++- .../src/typed/pptx/reading-order.test.ts | 24 ++++++++++++-- .../ooxml.js/src/typed/pptx/reading-order.ts | 32 ++++++++++++------- 6 files changed, 50 insertions(+), 21 deletions(-) diff --git a/packages/document-schema.js/src/content-json-schema-defs.ts b/packages/document-schema.js/src/content-json-schema-defs.ts index f1862a2b6..79a73a8b9 100644 --- a/packages/document-schema.js/src/content-json-schema-defs.ts +++ b/packages/document-schema.js/src/content-json-schema-defs.ts @@ -678,6 +678,7 @@ export const CONTENT_DEFS: Record = { fontScale: { type: "number", exclusiveMinimum: 0 }, lineSpacingReduction: { type: "number", minimum: 0 }, paintOrder: { type: "number" }, + readingOrder: { type: "number" }, sourcePath: { type: "string" }, source: { $ref: "#/$defs/SourceResidue" }, frames: { type: "array", items: { $ref: "#/$defs/LayoutFrame" } }, diff --git a/packages/document-schema.js/src/content.ts b/packages/document-schema.js/src/content.ts index ae26d196c..6e26f014a 100644 --- a/packages/document-schema.js/src/content.ts +++ b/packages/document-schema.js/src/content.ts @@ -697,6 +697,7 @@ export const ContentShapeSchema = z.object({ fontScale: z.number().positive().optional(), lineSpacingReduction: z.number().nonnegative().optional(), paintOrder: z.number().optional(), + readingOrder: z.number().optional(), // where this shape falls in the order a person reading the slide would take it, recovered from the shapes' geometry rather than from p:spTree order (which is z-order and bears no relation to layout). Expressed as a rank ON the shape, exactly as paintOrder is, rather than by ordering the shapes array: sourcePath is assigned as slides[N].shapes[N] and must keep naming the position it names, so the array stays in document order and a consumer reading a slide as prose sorts by this instead. Same plain z.number() as paintOrder and for the same reason -- a fractional value can be inserted between two existing ones later. Absent when the reader could not resolve an order sourcePath: z.string().optional(), // deterministic, document-order-derived path assigned by the format reader source: SourceResidueSchema.optional(), // quarantined residue -- opaque text this format carries and no other format interprets (src/source.ts) frames: z.array(LayoutFrameSchema).optional(), // this shape's own rendered position(s), once a layout pass has fused one in -- see FusedNode above diff --git a/packages/ooxml.js/src/index.ts b/packages/ooxml.js/src/index.ts index e85a3329f..e7104af99 100644 --- a/packages/ooxml.js/src/index.ts +++ b/packages/ooxml.js/src/index.ts @@ -329,12 +329,6 @@ export type { // --- pptx: a PresentationML reader resolving the placeholder -> layout -> master -> theme inheritance cascade and DrawingML geometry into slides of positioned, styled shapes. The flat half of readPptx above; read-only either way, since this package has no PresentationML writer. --- export { readPptxContent, PptxDocumentSchema } from "./typed/pptx/read"; export type { PptxDocument } from "./typed/pptx/read"; -// Opt-in reading-order projection over a slide's shapes. Deliberately not applied by readPptxContent -// itself: ContentShape.sourcePath is assigned during the shape-tree walk as slides[N].shapes[N], so -// reordering the array in place would either desynchronise those paths from the positions they name or -// redefine them away from document order. Exported so a consumer reading a slide as prose can sort, -// while one correlating by sourcePath keeps the order it has. -export { orderShapesForReading } from "./typed/pptx/reading-order"; // The lossy, cell-values-only xlsx reading view (sheet names, cell references, resolved values, formulas, merged ranges, defined names -- no formats, styles, geometry, or charts), with no write side and no ContentDocument shape. It held the name readXlsx until that name went to the package-native reader above; readXlsxWorkbook says what it returns, exactly as readXlsxContent beside it does. export { diff --git a/packages/ooxml.js/src/typed/pptx/read.ts b/packages/ooxml.js/src/typed/pptx/read.ts index 82912e519..724b5122c 100644 --- a/packages/ooxml.js/src/typed/pptx/read.ts +++ b/packages/ooxml.js/src/typed/pptx/read.ts @@ -48,6 +48,7 @@ import { textContent, } from "../util"; import { base64ToBytes } from "../../util/base64"; +import { assignReadingOrder } from "./reading-order"; import type { DefaultRunProperties, SlideInheritanceContext } from "./inherit"; import { readPlaceholderKey, @@ -1017,7 +1018,11 @@ function readSlide( shapes, ); } - return { size, shapes, notes: readNotes(pkg, slidePath) }; + return { + size, + shapes: assignReadingOrder(shapes), + notes: readNotes(pkg, slidePath), + }; } // Resolves a generic OOXML Package into PptxDocument: slide order via p:sldIdLst (never slide filename order), the placeholder -> layout -> master -> theme inheritance cascade, DrawingML geometry, and embedded images sniffed from their media parts. It is a one-way read, not a round-trip path, and a PptxDocument cannot be written back to a package. diff --git a/packages/ooxml.js/src/typed/pptx/reading-order.test.ts b/packages/ooxml.js/src/typed/pptx/reading-order.test.ts index 433964c71..da4196fc2 100644 --- a/packages/ooxml.js/src/typed/pptx/reading-order.test.ts +++ b/packages/ooxml.js/src/typed/pptx/reading-order.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { ContentShape } from "document-schema.js"; -import { orderShapesForReading } from "./reading-order"; +import { assignReadingOrder } from "./reading-order"; // A shape carrying only what the ordering looks at: its frame, and a name to assert the order by. function shape( @@ -21,10 +21,14 @@ function shape( }; } +// The names in reading order -- read back off the `readingOrder` ranks, since the array itself is +// deliberately returned in document order. const order = (shapes: ContentShape[]): (string | undefined)[] => - orderShapesForReading(shapes).map((s) => s.name); + [...assignReadingOrder(shapes)] + .sort((a, b) => (a.readingOrder ?? 0) - (b.readingOrder ?? 0)) + .map((s) => s.name); -describe("orderShapesForReading", () => { +describe("assignReadingOrder", () => { it("reads a two-column slide column by column, keeping each heading with its own list", () => { // The layout that motivates cutting on an axis rather than always sorting top-to-bottom: sorting by // y alone interleaves the columns and separates every heading from the bullets it introduces. @@ -92,4 +96,18 @@ describe("orderShapesForReading", () => { expect(order([])).toEqual([]); expect(order([shape("only", 10, 10, 10, 10)])).toEqual(["only"]); }); + + it("returns the array in document order, ranking rather than reordering", () => { + // The point of the whole design: sourcePath is assigned as slides[N].shapes[N], so the array must + // keep naming the positions it names. Only the ranks describe the reading order. + const shapes = [ + shape("right", 400, 60, 300, 200), + shape("left", 40, 60, 300, 200), + ]; + + const result = assignReadingOrder(shapes); + + expect(result.map((s) => s.name)).toEqual(["right", "left"]); + expect(result.map((s) => s.readingOrder)).toEqual([1, 0]); + }); }); diff --git a/packages/ooxml.js/src/typed/pptx/reading-order.ts b/packages/ooxml.js/src/typed/pptx/reading-order.ts index 715a32f85..a2f50541e 100644 --- a/packages/ooxml.js/src/typed/pptx/reading-order.ts +++ b/packages/ooxml.js/src/typed/pptx/reading-order.ts @@ -1,24 +1,34 @@ import type { Box, ContentShape } from "document-schema.js"; -// Orders a slide's shapes the way a person looking at it would take them, by recursive XY-cut, rather -// than in the order p:spTree happens to list them. +// Records where each shape falls in the order a person reading the slide would take them, as a +// `readingOrder` rank ON each shape -- the shapes array itself is returned untouched, in document order. // // p:spTree order is z-order -- roughly creation order -- and bears no relation to layout: a title box // drawn last sits last in the file, and a two-column slide interleaves its columns arbitrarily. That is -// survivable for a consumer rendering the shapes, since each carries its own frame and is positioned -// independently. It stops being survivable the moment a consumer reads a slide as prose: concatenating -// ContentSlide.shapes in array order then puts a column of bullets ahead of the heading that owns them, -// which the same deck exported to PDF does not do (a PDF renderer has already resolved layout to reading -// order). The geometry needed to fix it is already on every shape. +// fine for a consumer rendering the shapes, since each carries its own frame. It stops being fine for one +// reading a slide as prose, which in spTree order puts a column of bullets ahead of the heading that owns +// them -- something the same deck exported to PDF does not do, because a PDF renderer has already +// resolved layout to reading order. +// +// A rank rather than a reordered array, and that is the whole design: `sourcePath` is assigned as +// slides[N].shapes[N] and has to keep naming the position it names, so sorting the array in place would +// either desynchronise every path or redefine sourcePath away from the document order its own comment +// promises. Expressed exactly as `paintOrder` already is -- a plain number on the shape, non-integer by +// choice so a value can be inserted between two existing ones later -- so a consumer that wants reading +// order sorts by it, and one that does not is unaffected. // // Every shape reaching here has a real frame: resolveShapeFrame resolves a placeholder's inherited // a:xfrm through the layout/master cascade, and a shape whose geometry cannot be resolved at all is -// dropped rather than emitted at a default position. So there is no "geometry missing" case to guard -- -// which is not true of every implementation of this, and is worth knowing when comparing. -export function orderShapesForReading( +// dropped rather than emitted at a default position. So there is no "geometry missing" case to guard. +export function assignReadingOrder( shapes: readonly ContentShape[], ): ContentShape[] { - return cut([...shapes]); + const ranked = new Map(); + cut([...shapes]).forEach((shape, rank) => ranked.set(shape, rank)); + return shapes.map((shape) => ({ + ...shape, + readingOrder: ranked.get(shape) ?? 0, + })); } type Axis = "vertical" | "horizontal";