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

extendedDocumentPreview.ts

727 lines26.0 KB

1import { pipe } from "effect/Function";
2import {
3 IMAGE_DIMENSIONS_HEADER_BYTES,
4 readImageDimensions,
5} from "@t3tools/shared/imageDimensions";
6import { XMLParser, XMLValidator } from "fast-xml-parser";
7import { decodeHTMLAttribute, decodeHTMLStrict as decodeEntities } from "entities";
8import type JSZip from "jszip";
9import { terminalOutputPlainText } from "@t3tools/shared/terminalOutput";
10​
11import { type OfficeWorkerResult, type NotebookCell } from "./officePreviewProtocol";
12​
13const TABLE_PREVIEW_MAX_ROWS = 500;
14const TABLE_PREVIEW_MAX_COLUMNS = 50;
15​
16// `text` and `attrs` hold decoded values: escaped text is decoded once, CDATA stays literal.
17type XmlNode = { name: string; attrs: Record<string, string>; children: XmlNode[]; text: string };
18type OrderedXml = { [key: string]: OrderedXml[] | Record<string, string> | string };
19const escape = (value: string) =>
20 value
21 .replaceAll("&", "&amp;")
22 .replaceAll("<", "&lt;")
23 .replaceAll(">", "&gt;")
24 .replaceAll('"', "&quot;");
25​
26const namespacePrefixes: Record<string, string> = {
27 "urn:oasis:names:tc:opendocument:xmlns:office:1.0": "office",
28 "urn:oasis:names:tc:opendocument:xmlns:table:1.0": "table",
29 "urn:oasis:names:tc:opendocument:xmlns:text:1.0": "text",
30 "urn:oasis:names:tc:opendocument:xmlns:drawing:1.0": "draw",
31 "urn:oasis:names:tc:opendocument:xmlns:presentation:1.0": "presentation",
32 "http://www.w3.org/1999/xlink": "xlink",
33 "http://www.w3.org/1999/xhtml": "",
34 "http://www.idpf.org/2007/opf": "",
35 "urn:oasis:names:tc:opendocument:xmlns:container": "",
36};
37function parseXml(source: string): XmlNode {
38 // EPUB XHTML commonly declares an external DTD. Discard it without resolving it.
39 source = source.replaceAll(/<!DOCTYPE\s+[^<>[\]]*>/gi, "");
40 if (
41 source.length > 8 * 1024 * 1024 ||
42 /<!\s*(DOCTYPE|ENTITY)/i.test(source) ||
43 XMLValidator.validate(source) !== true
44 )
45 throw new Error("This document contains invalid or unsupported XML.");
46 const parser = new XMLParser({
47 preserveOrder: true,
48 ignoreAttributes: false,
49 attributeNamePrefix: "",
50 processEntities: false,
51 trimValues: false,
52 parseTagValue: false,
53 cdataPropName: "#cdata",
54 });
55 const ordered = parser.parse(source) as OrderedXml[];
56 let count = 0;
57 function convert(
58 items: OrderedXml[],
59 depth: number,
60 inherited: Record<string, string>,
61 ): XmlNode[] {
62 if (depth > 100) throw new Error("This document is nested too deeply to preview.");
63 return items.map((item) => {
64 if (++count > 100_000) throw new Error("This document has too much content to preview.");
65 const name = Object.keys(item).find((key) => key !== ":@") ?? "#text";
66 const value = item[name];
67 if (name === "#cdata") {
68 const literal = Array.isArray(value)
69 ? value.map((part) => part["#text"] ?? "").join("")
70 : "";
71 return { name: "#text", attrs: {}, children: [], text: literal };
72 }
73 const attrs = Object.fromEntries(
74 Object.entries((item[":@"] ?? {}) as Record<string, string>).map(([key, value]) => [
75 key,
76 decodeEntities(value),
77 ]),
78 );
79 const namespaces = Object.create(inherited) as Record<string, string>;
80 for (const [key, value] of Object.entries(attrs)) {
81 if (key === "xmlns") namespaces[""] = value;
82 else if (key.startsWith("xmlns:")) namespaces[key.slice(6)] = value;
83 }
84 const expanded = (name: string, attribute = false) => {
85 if (name.startsWith("#")) return name;
86 const colon = name.indexOf(":");
87 if (colon < 0 && attribute) return name;
88 const uri = namespaces[colon < 0 ? "" : name.slice(0, colon)];
89 if (!uri) return name;
90 const prefix = Object.hasOwn(namespacePrefixes, uri) ? namespacePrefixes[uri] : undefined;
91 const local = name.slice(colon + 1);
92 return prefix === undefined
93 ? "{" + uri + "}" + local
94 : prefix
95 ? prefix + ":" + local
96 : local;
97 };
98 return {
99 name: expanded(name),
100 attrs: Object.fromEntries(
101 Object.entries(attrs).map(([key, value]) => [expanded(key, true), value]),
102 ),
103 children: Array.isArray(value) ? convert(value, depth + 1, namespaces) : [],
104 text: typeof value === "string" ? decodeEntities(value) : "",
105 };
106 });
107 }
108 return {
109 name: "root",
110 attrs: {},
111 children: convert(ordered, 0, Object.create(null) as Record<string, string>),
112 text: "",
113 };
115function find(node: XmlNode, name: string, skip?: string): XmlNode[] {
116 return node.children.flatMap((child) =>
117 child.name === skip
118 ? []
119 : child.name === name || (!name.includes(":") && child.name.endsWith(`:${name}`))
120 ? [child]
121 : find(child, name, skip),
122 );
124function text(node: XmlNode): string {
125 if (node.name === "office:annotation") return "";
126 if (node.name === "text:s") return " ".repeat(repeat(node.attrs["text:c"], 100));
127 if (node.name === "text:tab") return "\t";
128 if (node.name === "text:line-break") return "\n";
129 return node.name === "#text" ? node.text : node.children.map(text).join("");
131function archivePath(base: string, href: string): string | null {
132 if (/^(?:[a-z][a-z\d+.-]*:|\/|\\)/i.test(href)) return null;
133 const parts = base.split("/").slice(0, -1);
134 let decoded: string;
135 try {
136 decoded = decodeURIComponent(href.split(/[?#]/, 1)[0] ?? "");
137 } catch {
138 return null;
139 }
140 for (const part of decoded.split("/")) {
141 if (part === "..") {
142 if (!parts.length) return null;
143 parts.pop();
144 } else if (part && part !== ".") parts.push(part);
145 }
146 return parts.join("/");
148function imageFits(bytes: Uint8Array) {
149 const size = readImageDimensions(bytes.subarray(0, IMAGE_DIMENSIONS_HEADER_BYTES));
150 return size !== null && size.width <= 16_777_216 / size.height;
152function base64ImageFits(base64: string) {
153 return (
154 /^[a-z\d+/]*={0,2}$/i.test(base64) &&
155 base64.length % 4 !== 1 &&
156 (!base64.endsWith("=") || base64.length % 4 === 0) &&
157 imageFits(
158 Uint8Array.from(
159 atob(base64.slice(0, 4 * Math.ceil(IMAGE_DIMENSIONS_HEADER_BYTES / 3))),
160 (char) => char.charCodeAt(0),
161 ),
162 )
163 );
165function htmlImagesFit(html: string) {
166 // HTML attribute entities can obscure a data URL until the client parses it.
167 for (const match of decodeHTMLAttribute(html).matchAll(
168 /data:image\/(?:png|jpeg|gif|webp);base64,([a-z\d+/]*={0,2})/gi,
169 ))
170 if (!base64ImageFits(match[1]!)) return false;
171 return true;
173async function imageData(archive: JSZip, path: string | null): Promise<string> {
174 if (!path) return "";
175 const mime = (
176 { png: "png", jpg: "jpeg", jpeg: "jpeg", gif: "gif", webp: "webp" } as Record<string, string>
177 )[path.split(".").at(-1)?.toLowerCase() ?? ""];
178 const entry = archive.file(path);
179 if (!mime || !entry) return "";
180 const bytes = await entry.async("uint8array");
181 if (bytes.length > 4 * 1024 * 1024 || !imageFits(bytes)) return "";
182 reserveMarkup(4 * Math.ceil(bytes.length / 3) + 100);
183 return `data:image/${mime};base64,${await entry.async("base64")}`;
185let markupBytes = 0;
186function reserveMarkup(size: number) {
187 markupBytes += size;
188 if (markupBytes > 16 * 1024 * 1024)
189 throw new Error("The rendered document is too large to preview.");
191function boundedMarkup(value: string): string {
192 reserveMarkup(value.length);
193 return value;
195const HTML_TAGS = new Set([
196 "p",
197 "h1",
198 "h2",
199 "h3",
200 "h4",
201 "h5",
202 "h6",
203 "div",
204 "span",
205 "strong",
206 "b",
207 "em",
208 "i",
209 "u",
210 "s",
211 "ul",
212 "ol",
213 "li",
214 "blockquote",
215 "pre",
216 "code",
217 "table",
218 "thead",
219 "tbody",
220 "tr",
221 "th",
222 "td",
223 "caption",
224 "figure",
225 "figcaption",
226 "br",
227 "hr",
228 "sub",
229 "sup",
230 "dl",
231 "dt",
232 "dd",
233]);
234async function epubHtml(node: XmlNode, archive: JSZip, base: string): Promise<string> {
235 if (node.name === "#text") return pipe(text(node), escape, boundedMarkup);
236 const tag =
237 node.name
238 .replace(/^\{[^}]*\}/, "")
239 .split(":")
240 .at(-1) ?? "";
241 if (["head", "script", "style", "iframe", "object", "embed", "svg", "math", "form"].includes(tag))
242 return "";
243 if (tag === "br" || tag === "hr") return boundedMarkup(`<${tag}>`);
244 if (tag === "img") {
245 const src = await imageData(archive, archivePath(base, node.attrs.src ?? ""));
246 return src ? `<img src="${src}" alt="${boundedMarkup(escape(node.attrs.alt ?? ""))}">` : "";
247 }
248 let children = "";
249 for (const child of node.children) children += await epubHtml(child, archive, base);
250 if (!HTML_TAGS.has(tag)) return children;
251 const spans =
252 tag === "td" || tag === "th"
253 ? ["colspan", "rowspan"]
254 .map((name) =>
255 /^\d{1,4}$/.test(node.attrs[name] ?? "") ? ` ${name}="${node.attrs[name]}"` : "",
256 )
257 .join("")
258 : "";
259 reserveMarkup(tag.length * 2 + spans.length + 5);
260 return `<${tag}${spans}>${children}</${tag}>`;
262async function odfHtml(node: XmlNode, archive: JSZip): Promise<string> {
263 if (node.name === "#text") return pipe(text(node), escape, boundedMarkup);
264 if (node.name === "text:s" || node.name === "text:tab")
265 return boundedMarkup(
266 `<span data-odf-whitespace>${node.name === "text:tab" ? "&#9;" : " ".repeat(repeat(node.attrs["text:c"], 100))}</span>`,
267 );
268 if (node.name === "text:line-break") return boundedMarkup("<br>");
269 if (node.name === "draw:image") {
270 const src = await imageData(
271 archive,
272 archivePath("content.xml", node.attrs["xlink:href"] ?? ""),
273 );
274 return src ? `<img src="${src}" alt="">` : "";
275 }
276 if (
277 [
278 "office:annotation",
279 "presentation:notes",
280 "text:tracked-changes",
281 "office:scripts",
282 "draw:object",
283 "draw:object-ole",
284 ].includes(node.name)
285 )
286 return "";
287 let children = "";
288 for (const child of node.children) children += await odfHtml(child, archive);
289 const tags: Record<string, string> = {
290 "text:p": "p",
291 "text:h": "h2",
292 "text:span": "span",
293 "text:list": "ul",
294 "text:list-item": "li",
295 "table:table": "table",
296 "table:table-row": "tr",
297 "table:table-cell": "td",
298 };
299 const tag = tags[node.name];
300 if (!tag) return children;
301 const count =
302 tag === "tr"
303 ? repeat(node.attrs["table:number-rows-repeated"], TABLE_PREVIEW_MAX_ROWS)
304 : tag === "td"
305 ? repeat(node.attrs["table:number-columns-repeated"], TABLE_PREVIEW_MAX_COLUMNS)
306 : 1;
307 const markup = `<${tag}>${children}</${tag}>`;
308 reserveMarkup(count * markup.length - children.length);
309 return markup.repeat(count);
311function repeat(value: string | undefined, max: number) {
312 const count = Number(value ?? 1);
313 if (!Number.isSafeInteger(count) || count < 1)
314 throw new Error("This document contains invalid repeated content.");
315 return Math.min(count, max);
317export async function openDocument(
318 archive: JSZip,
319 kind: "odt" | "ods" | "odp",
320): Promise<OfficeWorkerResult> {
321 markupBytes = 0;
322 const source = await archive.file("content.xml")?.async("string");
323 if (!source) throw new Error("This OpenDocument file has no readable content.");
324 const root = parseXml(source);
325 if (kind === "ods") {
326 const tables = find(root, "table:table");
327 if (!tables.length || tables.length > 100)
328 throw new Error("This workbook has no sheets or too many sheets to preview.");
329 let cellBytes = 0;
330 return {
331 type: "ods",
332 sheets: tables.map((table, index) => {
333 const rows: string[][] = [];
334 let truncated = false;
335 let pendingEmptyRows = 0;
336 for (const row of find(table, "table:table-row")) {
337 const cells: string[] = [];
338 for (const cell of row.children.filter((child) =>
339 ["table:table-cell", "table:covered-table-cell"].includes(child.name),
340 )) {
341 const value =
342 find(cell, "text:p", "office:annotation").map(text).join("\n") ||
343 cell.attrs["office:value"] ||
344 cell.attrs["office:date-value"] ||
345 cell.attrs["office:time-value"] ||
346 cell.attrs["office:boolean-value"] ||
347 cell.attrs["office:string-value"] ||
348 "";
349 const count = repeat(
350 cell.attrs["table:number-columns-repeated"],
351 TABLE_PREVIEW_MAX_COLUMNS + 1,
352 );
353 if (value && cells.length + count > TABLE_PREVIEW_MAX_COLUMNS) truncated = true;
354 for (let i = 0; i < count && cells.length < TABLE_PREVIEW_MAX_COLUMNS; i++) {
355 if (value.length > 10_000)
356 throw new Error("A spreadsheet cell is too large to preview.");
357 cells.push(value);
358 }
359 }
360 while (cells.at(-1) === "") cells.pop();
361 const count = repeat(row.attrs["table:number-rows-repeated"], TABLE_PREVIEW_MAX_ROWS + 1);
362 if (!cells.length) {
363 pendingEmptyRows = Math.min(TABLE_PREVIEW_MAX_ROWS + 1, pendingEmptyRows + count);
364 continue;
365 }
366 while (pendingEmptyRows > 0 && rows.length < TABLE_PREVIEW_MAX_ROWS) {
367 rows.push([]);
368 pendingEmptyRows--;
369 }
370 pendingEmptyRows = 0;
371 if (rows.length + count > TABLE_PREVIEW_MAX_ROWS) truncated = true;
372 if (rows.length >= TABLE_PREVIEW_MAX_ROWS) break;
373 for (let i = 0; i < count && rows.length < TABLE_PREVIEW_MAX_ROWS; i++) {
374 cellBytes += cells.reduce((size, cell) => size + cell.length, 0);
375 if (cellBytes > 8 * 1024 * 1024)
376 throw new Error("This spreadsheet has too much text to preview.");
377 rows.push(cells);
378 }
379 }
380 return {
381 name: table.attrs["table:name"] || `Sheet ${index + 1}`,
382 rows,
383 rowCount: rows.length,
384 columns: rows.reduce((columns, row) => Math.max(columns, row.length), 1),
385 truncated,
386 };
387 }),
388 };
389 }
390 const pages = kind === "odp" ? find(root, "draw:page") : find(root, "office:text");
391 if (!pages.length || pages.length > 500)
392 throw new Error("This document has no pages or too many pages to preview.");
393 const rendered = [];
394 for (const [index, page] of pages.entries()) {
395 rendered.push({
396 title: page.attrs["draw:name"] || `Page ${index + 1}`,
397 html: await odfHtml(page, archive),
398 });
399 }
400 return { type: "document", navigation: kind === "odp" ? "slide" : "page", pages: rendered };
402export async function openEpub(archive: JSZip): Promise<OfficeWorkerResult> {
403 markupBytes = 0;
404 const container = parseXml((await archive.file("META-INF/container.xml")?.async("string")) ?? "");
405 const rootfile = find(container, "rootfile")[0]?.attrs["full-path"];
406 if (!rootfile || !archive.file(rootfile)) throw new Error("This EPUB has no readable package.");
407 const packageRoot = parseXml(await archive.file(rootfile)!.async("string"));
408 const manifest = new Map(find(packageRoot, "item").map((item) => [item.attrs.id, item]));
409 const spine = find(packageRoot, "itemref");
410 if (!spine.length || spine.length > 500)
411 throw new Error("This EPUB has no chapters or too many chapters to preview.");
412 const pages = [];
413 for (const itemref of spine) {
414 const item = manifest.get(itemref.attrs.idref);
415 if (!item || item.attrs["media-type"] !== "application/xhtml+xml") continue;
416 const path = archivePath(rootfile, item.attrs.href ?? "");
417 const entry = path ? archive.file(path) : null;
418 if (!entry || !path) continue;
419 const chapter = parseXml(await entry.async("string"));
420 pages.push({
421 title:
422 text(find(chapter, "title")[0] ?? find(chapter, "h1")[0] ?? chapter).slice(0, 120) ||
423 `Chapter ${pages.length + 1}`,
424 html: await epubHtml(chapter, archive, path),
425 });
426 }
427 if (!pages.length) throw new Error("This EPUB has no readable XHTML chapters.");
428 return { type: "document", navigation: "chapter", pages };
430function record(value: unknown): Record<string, unknown> {
431 return value !== null && typeof value === "object" && !Array.isArray(value)
432 ? (value as Record<string, unknown>)
433 : {};
435function notebookText(value: unknown, limit = 256_000): string {
436 const result =
437 typeof value === "string"
438 ? value
439 : Array.isArray(value)
440 ? value.filter((part) => typeof part === "string").join("")
441 : "";
442 if (result.length > limit) throw new Error("A notebook cell is too large to preview.");
443 return result;
445// IPython traceback frames carry ANSI SGR colors and may omit a final newline.
446function tracebackLines(value: unknown): unknown {
447 if (!Array.isArray(value)) return value;
448 const frames = value
449 .filter((frame): frame is string => typeof frame === "string")
450 .map(terminalOutputPlainText);
451 return frames.map((frame, index) =>
452 index && !frames[index - 1]!.endsWith("\n") ? `\n${frame}` : frame,
453 );
455export function openNotebook(bytes: ArrayBuffer): OfficeWorkerResult {
456 if (bytes.byteLength > 8 * 1024 * 1024)
457 throw new Error("Notebook preview limited to files under 8 MB.");
458 const notebook = record(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)));
459 if (notebook.nbformat !== 4 || !Array.isArray(notebook.cells) || notebook.cells.length > 500)
460 throw new Error("Preview supports version 4 notebooks with up to 500 cells.");
461 const language = String(record(record(notebook.metadata).language_info).name ?? "text")
462 .replaceAll(/[^a-z\d+#-]/gi, "")
463 .slice(0, 40);
464 const cells: NotebookCell[] = notebook.cells.map((value) => {
465 const cell = record(value);
466 const outputs = Array.isArray(cell.outputs) ? cell.outputs : [];
467 if (outputs.length > 100) throw new Error("A notebook cell has too many outputs to preview.");
468 return {
469 kind: cell.cell_type === "markdown" ? "markdown" : cell.cell_type === "code" ? "code" : "raw",
470 source: notebookText(cell.source),
471 outputs: outputs
472 .map((value) => {
473 const output = record(value);
474 const data = record(output.data);
475 for (const mime of ["image/png", "image/jpeg", "image/gif", "image/webp"]) {
476 if (data[mime]) {
477 const base64 = notebookText(data[mime], 4 * 1024 * 1024).replaceAll(/\s/g, "");
478 if (base64ImageFits(base64))
479 return { kind: "image" as const, text: `data:${mime};base64,${base64}` };
480 }
481 }
482 const html = notebookText(data["text/html"]);
483 if (html.trim() && htmlImagesFit(html)) return { kind: "html" as const, text: html };
484 return {
485 kind: "text" as const,
486 text: notebookText(
487 output.text ?? data["text/plain"] ?? tracebackLines(output.traceback),
488 ),
489 };
490 })
491 .filter((output) => output.text.trim().length > 0),
492 };
493 });
494 return { type: "notebook", language, cells };
496​
497// Characters RTF writes as control symbols (`\~`) and control words (`\emspace`).
498const RTF_CHARACTERS = new Map([
499 ["~", "\u00a0"],
500 ["_", "\u2011"],
501 ["-", "\u00ad"],
502 ["tab", "\t"],
503 ["cell", "\t"],
504 ["endash", "–"],
505 // oxlint-disable-next-line t3code/no-em-dash-copy -- Preserve the RTF document's punctuation.
506 ["emdash", "—"],
507 ["bullet", "•"],
508 ["lquote", "‘"],
509 ["rquote", "’"],
510 ["ldblquote", "“"],
511 ["rdblquote", "”"],
512 ["enspace", "\u2002"],
513 ["emspace", "\u2003"],
514 ["qmspace", "\u2005"],
515 ["zwbo", "\u200b"],
516 ["zwnj", "\u200c"],
517 ["zwj", "\u200d"],
518 ["ltrmark", "\u200e"],
519 ["rtlmark", "\u200f"],
520]);
521​
522export function openRtf(bytes: ArrayBuffer): OfficeWorkerResult {
523 if (bytes.byteLength > 8 * 1024 * 1024)
524 throw new Error("Rich text preview limited to files under 8 MB.");
525 // Keep byte offsets intact for binary destinations and multibyte code pages.
526 const input = new Uint8Array(bytes);
527 let source = "";
528 for (let offset = 0; offset < input.length; offset += 8192)
529 source += String.fromCharCode(...input.subarray(offset, offset + 8192));
530 if (!source.startsWith("{\\rtf")) throw new Error("This is not a valid rich text document.");
531 let state = {
532 skip: false,
533 bold: false,
534 italic: false,
535 underline: false,
536 uc: 1,
537 font: 0,
538 fontTable: false,
539 decoder: new TextDecoder("windows-1252"),
540 };
541 const fonts = new Map<number, TextDecoder>();
542 let defaultFont = 0;
543 const stack: (typeof state)[] = [];
544 let html = "";
545 const pages: { title: string; html: string }[] = [];
546 let completedLength = 0;
547 const finishPage = () => {
548 if (pages.length >= 500) throw new Error("Rich text preview limited to 500 pages.");
549 pages.push({ title: `Page ${pages.length + 1}`, html: `<p>${html}</p>` });
550 completedLength += html.length;
551 html = "";
552 };
553 let run = "";
554 let fallback = 0;
555 const encoded = new Uint8Array(bytes.byteLength);
556 let encodedLength = 0;
557 const flush = () => {
558 if (encodedLength) {
559 run += escape(
560 (fonts.get(state.font) ?? state.decoder).decode(encoded.subarray(0, encodedLength)),
561 );
562 encodedLength = 0;
563 }
564 if (completedLength + html.length + run.length > 8 * 1024 * 1024)
565 throw new Error("This rich text document has too much content to preview.");
566 if (!run) return;
567 let span = run;
568 if (state.bold) span = `<strong>${span}</strong>`;
569 if (state.italic) span = `<em>${span}</em>`;
570 if (state.underline) span = `<u>${span}</u>`;
571 html += span;
572 run = "";
573 };
574 const emit = (value: string) => {
575 if (fallback > 0) {
576 fallback--;
577 return;
578 }
579 if (state.skip) return;
580 run += escape(value);
581 if (completedLength + html.length + run.length > 8 * 1024 * 1024)
582 throw new Error("This rich text document has too much content to preview.");
583 };
584 const emitByte = (value: number) => {
585 if (fallback > 0) fallback--;
586 else if (!state.skip) encoded[encodedLength++] = value;
587 };
588 for (let i = 0; i < source.length;) {
589 const char = source[i++];
590 if (char === "{") {
591 flush();
592 fallback = 0;
593 if (stack.length > 100) throw new Error("This rich text document is nested too deeply.");
594 stack.push({ ...state });
595 } else if (char === "}") {
596 flush();
597 fallback = 0;
598 const parent = stack.pop();
599 if (!parent) throw new Error("This rich text document is damaged.");
600 state = parent;
601 } else if (char === "\\") {
602 const next = source[i];
603 if (next === "'") {
604 const hex = source.slice(i + 1, i + 3);
605 if (!/^[\da-f]{2}$/i.test(hex)) throw new Error("Invalid rich text byte escape.");
606 i += 3;
607 emitByte(Number.parseInt(hex, 16));
608 continue;
609 }
610 flush();
611 if (next === "\\" || next === "{" || next === "}") {
612 i++;
613 emit(next);
614 continue;
615 }
616 if (next === "*") {
617 state.skip = true;
618 i++;
619 continue;
620 }
621 const match = /^([a-z]+)(-?\d+)? ?/i.exec(source.slice(i));
622 if (!match) {
623 const character = RTF_CHARACTERS.get(next!);
624 if (character) emit(character);
625 // A backslash before a line break is \par; TextEdit ends paragraphs this way.
626 else if ((next === "\n" || next === "\r") && !state.skip) html += "<br>";
627 i++;
628 continue;
629 }
630 i += match[0].length;
631 const word = match[1];
632 const arg = match[2] === undefined ? 1 : Number(match[2]);
633 if (word === "fonttbl") {
634 state.fontTable = true;
635 state.skip = true;
636 } else if (word === "deff") state.font = defaultFont = arg;
637 else if (word === "f") state.font = arg;
638 else if (word === "fcharset" && state.fontTable) {
639 const label = (
640 {
641 0: state.decoder.encoding,
642 1: state.decoder.encoding,
643 77: "macintosh",
644 128: "shift_jis",
645 129: "euc-kr",
646 134: "gbk",
647 136: "big5",
648 161: "windows-1253",
649 162: "windows-1254",
650 163: "windows-1258",
651 177: "windows-1255",
652 178: "windows-1256",
653 186: "windows-1257",
654 204: "windows-1251",
655 222: "windows-874",
656 238: "windows-1250",
657 255: "ibm866",
658 } as Record<number, string>
659 )[arg];
660 if (label) fonts.set(state.font, new TextDecoder(label));
661 } else if (
662 [
663 "colortbl",
664 "stylesheet",
665 "info",
666 "pict",
667 "object",
668 "fldinst",
669 "header",
670 "headerl",
671 "headerr",
672 "headerf",
673 "footer",
674 "footerl",
675 "footerr",
676 "footerf",
677 "filetbl",
678 "datastore",
679 "themedata",
680 ].includes(word!)
681 )
682 state.skip = true;
683 else if (word === "bin") {
684 if (!Number.isSafeInteger(arg) || arg < 0 || arg > source.length - i)
685 throw new Error("Invalid rich text binary data.");
686 i += arg;
687 } else if (word === "ansicpg") {
688 const label =
689 (
690 {
691 65001: "utf-8",
692 932: "shift_jis",
693 936: "gbk",
694 949: "euc-kr",
695 950: "big5",
696 874: "windows-874",
697 } as Record<number, string>
698 )[arg] ?? `windows-${arg}`;
699 try {
700 state.decoder = new TextDecoder(label);
701 } catch {
702 throw new Error("This rich text code page is not supported.");
703 }
704 } else if (word === "b") state.bold = arg !== 0;
705 else if (word === "i") state.italic = arg !== 0;
706 else if (word === "ul") state.underline = arg !== 0;
707 else if (word === "ulnone") state.underline = false;
708 else if (word === "plain")
709 state = { ...state, bold: false, italic: false, underline: false, font: defaultFont };
710 else if (word === "uc") state.uc = Math.max(0, Math.min(10, arg));
711 else if (word === "u") {
712 fallback = 0;
713 emit(String.fromCharCode(arg < 0 ? arg + 65536 : arg));
714 fallback = state.uc;
715 } else if (!state.skip && word === "page") finishPage();
716 else if (!state.skip && (word === "par" || word === "line" || word === "row")) html += "<br>";
717 else {
718 const character = RTF_CHARACTERS.get(word!);
719 if (character) emit(character);
720 }
721 } else if (char !== "\r" && char !== "\n" && char) emitByte(char.charCodeAt(0));
722 }
723 if (stack.length) throw new Error("This rich text document is damaged.");
724 flush();
725 if (html || !pages.length) finishPage();
726 return { type: "document", navigation: "page", pages };