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/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 new file mode 100644 index 000000000..da4196fc2 --- /dev/null +++ b/packages/ooxml.js/src/typed/pptx/reading-order.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest"; +import type { ContentShape } from "document-schema.js"; +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( + 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: [], + }; +} + +// 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)[] => + [...assignReadingOrder(shapes)] + .sort((a, b) => (a.readingOrder ?? 0) - (b.readingOrder ?? 0)) + .map((s) => s.name); + +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. + 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"]); + }); + + 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 new file mode 100644 index 000000000..a2f50541e --- /dev/null +++ b/packages/ooxml.js/src/typed/pptx/reading-order.ts @@ -0,0 +1,125 @@ +import type { Box, ContentShape } from "document-schema.js"; + +// 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 +// 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. +export function assignReadingOrder( + shapes: readonly ContentShape[], +): ContentShape[] { + 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"; + +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 }; +}