Skip to content
Source
Sign inSign up
Open History to see the last commit

slide-text.ts

423 lines14.2 KB

1// Adapted from opencode (MIT). Copyright (c) 2025 opencode. See LICENSE.opencode.
2import { pipe } from "effect/Function";
3import * as Option from "effect/Option";
4import * as Schema from "effect/Schema";
5import { inspectPresentation, type DeckSnapshot } from "@betteroffice/pptx";
6​
7/** A text style a deck draws with. */
8export type FontStyle = { readonly bold: boolean; readonly italic: boolean };
9​
10/** A family the deck names, with the styles its text asks for. */
11export type DeckFamily = { readonly name: string; readonly styles: readonly FontStyle[] };
12​
13/** The scripts the bundled Latin faces may lack, which have a bundled fallback face. */
14export type FallbackScript = "hebrew" | "arabic" | "cjk-jp" | "cjk-kr" | "cjk-sc";
15​
16/** What a deck's text needs from the fonts, and which slides it hides. */
17export type DeckText = {
18 /** Most important first: the face registered first also draws every family the deck names but nobody found. */
19 readonly families: readonly DeckFamily[];
20 readonly scripts: readonly FallbackScript[];
21 /** One flag per slide, in deck order. */
22 readonly hidden: readonly boolean[];
23};
24​
25const RunStyle = Schema.Struct({
26 bold: Schema.optional(Schema.NullOr(Schema.Boolean)),
27 italic: Schema.optional(Schema.NullOr(Schema.Boolean)),
28 fontFamily: Schema.optional(Schema.NullOr(Schema.String)),
29});
30​
31const Body = Schema.Struct({
32 paragraphs: Schema.Array(
33 Schema.Struct({
34 properties: Schema.optional(
35 Schema.NullOr(
36 Schema.Struct({
37 defaultRun: Schema.optional(Schema.NullOr(RunStyle)),
38 }),
39 ),
40 ),
41 runs: Schema.Array(
42 Schema.Struct({
43 text: Schema.String,
44 properties: Schema.optional(Schema.NullOr(RunStyle)),
45 }),
46 ),
47 }),
48 ),
49});
50​
51type Drawing = {
52 readonly text?: typeof Body.Type | null | undefined;
53 readonly children?: readonly Drawing[] | undefined;
54 readonly data?: typeof Table.Type | null | undefined;
55};
56​
57const Table = Schema.Struct({
58 rows: Schema.optional(
59 Schema.Array(
60 Schema.Struct({
61 cells: Schema.Array(Schema.Struct({ text: Schema.optional(Schema.NullOr(Body)) })),
62 }),
63 ),
64 ),
65});
66​
67/** A slide object: a shape, group, picture or graphic frame. */
68const Drawing: Schema.Codec<Drawing> = Schema.Struct({
69 text: Schema.optional(Schema.NullOr(Body)),
70 children: Schema.optional(Schema.Array(Schema.suspend((): Schema.Codec<Drawing> => Drawing))),
71 // A table's cells; other frames, such as charts, carry no rows.
72 data: Schema.optional(Schema.NullOr(Table)),
73});
74​
75const ThemeFont = Schema.Struct({ latin: Schema.String });
76​
77const Inspected = Schema.Struct({
78 slides: Schema.Array(
79 Schema.Struct({
80 hidden: Schema.optional(Schema.Boolean),
81 drawings: Schema.Array(Drawing),
82 }).pipe(Schema.encodeKeys({ drawings: "shapes" })),
83 ),
84 layouts: Schema.optional(
85 Schema.Array(
86 Schema.Struct({ drawings: Schema.Array(Drawing) }).pipe(
87 Schema.encodeKeys({ drawings: "shapes" }),
88 ),
89 ),
90 ),
91 masters: Schema.optional(
92 Schema.Array(
93 Schema.Struct({
94 drawings: Schema.Array(Drawing),
95 textStyles: Schema.optional(
96 Schema.NullOr(
97 Schema.Record(
98 Schema.String,
99 Schema.Array(Schema.Struct({ defaultRun: Schema.optional(Schema.NullOr(RunStyle)) })),
100 ),
101 ),
102 ),
103 }).pipe(Schema.encodeKeys({ drawings: "shapes" })),
104 ),
105 ),
106 themes: Schema.optional(
107 Schema.Array(
108 Schema.Struct({
109 theme: Schema.optional(
110 Schema.NullOr(
111 Schema.Struct({
112 fontScheme: Schema.optional(
113 Schema.NullOr(Schema.Struct({ majorFont: ThemeFont, minorFont: ThemeFont })),
114 ),
115 }),
116 ),
117 ),
118 }),
119 ),
120 ),
121});
122​
123const decodeInspected = Schema.decodeUnknownOption(Inspected);
124​
125const scriptRanges: readonly (readonly [FallbackScript, RegExp])[] = [
126 ["hebrew", /[\u0590-\u05FF\uFB1D-\uFB4F]/u],
127 ["arabic", /[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]/u],
128 ["cjk-jp", /[\u3040-\u30FF\u31F0-\u31FF\uFF66-\uFF9F]/u],
129 ["cjk-kr", /[\u1100-\u11FF\u3130-\u318F\uAC00-\uD7AF]/u],
130];
131​
132const hanRange = /[\u3000-\u303F\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF\uFF00-\uFFEF]/u;
133​
134function scripts(text: string): FallbackScript[] {
135 const found = scriptRanges.flatMap((entry) => (entry[1].test(text) ? [entry[0]] : []));
136​
137 // The Japanese and Korean faces also draw the Chinese characters their text mixes in.
138 return hanRange.test(text) && !found.some((script) => script.startsWith("cjk-"))
139 ? [...found, "cjk-sc"]
140 : found;
142​
143/**
144 * Reads the families, styles and scripts a deck's text uses, from its slides, layouts, masters and theme. When the
145 * engine's inspection does not decode, the slides' own runs stand in, without theme fonts or hidden flags.
146 */
147export function deckText(bytes: Uint8Array, snapshot: DeckSnapshot): DeckText {
148 return pipe(
149 withoutMedia(bytes),
150 inspectPresentation,
151 decodeInspected,
152 Option.match({
153 onNone: () => fromSnapshot(snapshot),
154 onSome: (inspected) => {
155 const schemes =
156 inspected.themes?.flatMap((theme) =>
157 theme.theme?.fontScheme ? [theme.theme.fontScheme] : [],
158 ) ?? [];
159 const minor = schemes[0]?.minorFont.latin ?? "";
160​
161 const drawings = [
162 ...inspected.slides.flatMap((slide) => slide.drawings),
163 ...(inspected.layouts ?? []).flatMap((layout) => layout.drawings),
164 ...(inspected.masters ?? []).flatMap((master) => master.drawings),
165 ];
166​
167 const defaults = (inspected.masters ?? []).flatMap((master) =>
168 Object.values(master.textStyles ?? {}).flatMap((levels) =>
169 levels.flatMap((level) =>
170 level.defaultRun ? [{ text: "", style: level.defaultRun }] : [],
171 ),
172 ),
173 );
174​
175 return summarize(
176 [...drawings.flatMap(drawingRuns), ...defaults].flatMap((run) => {
177 const family = run.style?.fontFamily;
178 if (
179 !schemes.length ||
180 (family && !family.startsWith("+mn") && !family.startsWith("+mj"))
181 )
182 return [run];
183 return schemes.map((scheme) => ({
184 ...run,
185 style: {
186 ...run.style,
187 fontFamily: family?.startsWith("+mj")
188 ? scheme.majorFont.latin
189 : scheme.minorFont.latin,
190 },
191 }));
192 }),
193 (family) => (family?.startsWith("+") ? "" : (family ?? "")),
194 minor,
195 inspected.slides.map((slide) => slide.hidden ?? false),
196 );
197 },
198 }),
199 );
201​
202/** A file in the deck's zip package: where its central directory record and its local data sit. */
203type Entry = {
204 readonly name: string;
205 readonly record: number;
206 readonly length: number;
207 readonly local: number;
208};
209​
210/**
211 * The package without its pictures and media. The inspection reads only the deck's text and styles, but returns every
212 * picture as base64, which for a deck of photos costs tens of megabytes of engine memory the worker never gives back.
213 * Entries keep their compressed bytes. A package this cannot read, such as a ZIP64 one, stays as it is.
214 */
215function withoutMedia(bytes: Uint8Array): Uint8Array {
216 const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
217 const end = endOfDirectory(view);
218​
219 if (end === undefined) return bytes;
220​
221 const count = view.getUint16(end + 10, true);
222 const directory = view.getUint32(end + 16, true);
223​
224 if (count === 0xffff || directory === 0xffffffff || directory > end) return bytes;
225​
226 const entries = readEntries(view, directory, end, count);
227​
228 if (!entries || !entries.some((entry) => entry.name.startsWith("ppt/media/"))) return bytes;
229​
230 // An entry's local header and data run up to the next entry's, or to the central directory.
231 const starts = entries.map((entry) => entry.local).toSorted((left, right) => left - right);
232​
233 const kept = entries
234 .filter((entry) => !entry.name.startsWith("ppt/media/"))
235 .map((entry) => ({
236 entry,
237 start: entry.local,
238 end: starts.find((start) => start > entry.local) ?? directory,
239 }));
240​
241 // Where each kept entry's local data and directory record land in the new package.
242 const locals = offsets(kept.map((part) => part.end - part.start));
243 const records = offsets(kept.map((part) => part.entry.length));
244 const size = locals.at(-1) ?? 0;
245 const directorySize = records.at(-1) ?? 0;
246 const out = new Uint8Array(size + directorySize + 22);
247 const writer = new DataView(out.buffer);
248​
249 kept.forEach((part, index) => {
250 const local = locals[index] ?? 0;
251 const record = size + (records[index] ?? 0);
252​
253 out.set(bytes.subarray(part.start, part.end), local);
254 out.set(bytes.subarray(part.entry.record, part.entry.record + part.entry.length), record);
255 writer.setUint32(record + 42, local, true);
256 });
257​
258 const tail = size + directorySize;
259​
260 out.set(bytes.subarray(end, end + 22), tail);
261 writer.setUint16(tail + 8, kept.length, true);
262 writer.setUint16(tail + 10, kept.length, true);
263 writer.setUint32(tail + 12, directorySize, true);
264 writer.setUint32(tail + 16, size, true);
265 writer.setUint16(tail + 20, 0, true);
266​
267 return out;
269​
270/** Where each length starts when they are laid end to end, then where the last ends. */
271function offsets(lengths: readonly number[]) {
272 return lengths.reduce(
273 (list, length) => {
274 list.push((list.at(-1) ?? 0) + length);
275​
276 return list;
277 },
278 [0],
279 );
281​
282/** Where the end of central directory record starts, searched back from the end past any comment. */
283function endOfDirectory(view: DataView) {
284 const last = view.byteLength - 22;
285 const first = Math.max(0, last - 0xffff);
286​
287 for (let at = last; at >= first; at--) if (view.getUint32(at, true) === 0x06054b50) return at;
288​
289 return undefined;
291​
292/**
293 * The central directory's records, from `directory` up to the end record at `end`; undefined when one is malformed: it
294 * runs past the directory, or its local header does not sit before it.
295 */
296function readEntries(view: DataView, directory: number, end: number, count: number) {
297 const decoder = new TextDecoder();
298​
299 return Array.from({ length: count }).reduce<{ at: number; entries: Entry[] } | undefined>(
300 (state) => {
301 if (!state || state.at + 46 > end || view.getUint32(state.at, true) !== 0x02014b50)
302 return undefined;
303​
304 const nameLength = view.getUint16(state.at + 28, true);
305 const length =
306 46 + nameLength + view.getUint16(state.at + 30, true) + view.getUint16(state.at + 32, true);
307 const local = view.getUint32(state.at + 42, true);
308​
309 // A local header is 30 bytes before its name and data.
310 if (state.at + length > end || local + 30 > directory) return undefined;
311​
312 const name = decoder.decode(
313 new Uint8Array(view.buffer, view.byteOffset + state.at + 46, nameLength),
314 );
315​
316 state.entries.push({ name, record: state.at, length, local });
317​
318 return { at: state.at + length, entries: state.entries };
319 },
320 { at: directory, entries: [] },
321 )?.entries;
323​
324type Run = { readonly text: string; readonly style: typeof RunStyle.Type | null | undefined };
325​
326function drawingRuns(drawing: Drawing): Run[] {
327 return [
328 ...bodyRuns(drawing.text),
329 ...(drawing.data?.rows ?? []).flatMap((row) =>
330 row.cells.flatMap((cell) => bodyRuns(cell.text)),
331 ),
332 ...(drawing.children ?? []).flatMap(drawingRuns),
333 ];
335​
336function bodyRuns(body: typeof Body.Type | null | undefined): Run[] {
337 return (body?.paragraphs ?? []).flatMap((paragraph) =>
338 paragraph.runs.map((run) => ({
339 text: run.text,
340 style: {
341 fontFamily: run.properties?.fontFamily ?? paragraph.properties?.defaultRun?.fontFamily,
342 bold: run.properties?.bold ?? paragraph.properties?.defaultRun?.bold,
343 italic: run.properties?.italic ?? paragraph.properties?.defaultRun?.italic,
344 },
345 })),
346 );
348​
349function fromSnapshot(snapshot: DeckSnapshot): DeckText {
350 const runs = (drawings: DeckSnapshot["slides"][number]["shapes"]): Run[] =>
351 drawings.flatMap((drawing) => [
352 ...drawing.textStories.flatMap((story) =>
353 story.paragraphs.flatMap((paragraph) =>
354 paragraph.runs.map((run) => ({ text: run.text, style: run.style })),
355 ),
356 ),
357 ...runs(drawing.children),
358 ]);
359​
360 return summarize(
361 snapshot.slides.flatMap((slide) => runs(slide.shapes)),
362 (family) => family ?? "",
363 "",
364 snapshot.slides.map(() => false),
365 );
367​
368/**
369 * Groups runs by the family they resolve to: the theme's body font first, then by how much text each draws. The
370 * masters' default runs count too, so a theme font that placeholders inherit without naming it is still found.
371 */
372function summarize(
373 runs: readonly Run[],
374 resolve: (family: string | null | undefined) => string,
375 theme: string,
376 hidden: readonly boolean[],
377): DeckText {
378 const families = new Map<
379 string,
380 { name: string; weight: number; styles: Map<string, FontStyle> }
381 >();
382 const body = theme.trim().toLowerCase();
383 const text = runs.map((run) => run.text).join("");
384​
385 runs.forEach((run) => {
386 const name = resolve(run.style?.fontFamily).trim();
387​
388 if (!name) return;
389​
390 const style = { bold: run.style?.bold ?? false, italic: run.style?.italic ?? false };
391 const family = families.get(name.toLowerCase()) ?? {
392 name,
393 weight: 0,
394 styles: new Map<string, FontStyle>(),
395 };
396​
397 family.weight += run.text.length;
398 family.styles.set(`${style.bold}|${style.italic}`, style);
399 families.set(name.toLowerCase(), family);
400 });
401​
402 return {
403 families: [...families.entries()]
404 .toSorted((left, right) => rank(right, body) - rank(left, body))
405 .map((entry) => ({
406 name: entry[1].name,
407 // Regular first, so a family's first face is the one its unstyled text draws with.
408 styles: [...entry[1].styles.values()].toSorted(
409 (left, right) => styleRank(left) - styleRank(right),
410 ),
411 })),
412 scripts: scripts(text),
413 hidden,
414 };
416​
417function rank(entry: readonly [string, { readonly weight: number }], body: string) {
418 return entry[0] === body ? Number.POSITIVE_INFINITY : entry[1].weight;
420​
421function styleRank(style: FontStyle) {
422 return (style.bold ? 1 : 0) + (style.italic ? 2 : 0);