Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -678,6 +678,7 @@ export const CONTENT_DEFS: Record<string, JsonSchema> = {
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" } },
Expand Down
1 change: 1 addition & 0 deletions packages/document-schema.js/src/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion packages/ooxml.js/src/typed/pptx/read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
113 changes: 113 additions & 0 deletions packages/ooxml.js/src/typed/pptx/reading-order.test.ts
Original file line number Diff line number Diff line change
@@ -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]);
});
});
125 changes: 125 additions & 0 deletions packages/ooxml.js/src/typed/pptx/reading-order.ts
Original file line number Diff line number Diff line change
@@ -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<ContentShape, number>();
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 };
}
Loading