@intentic/fileq 1.317.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +46 -0
- package/dist/app.d.ts +2 -0
- package/dist/app.d.ts.map +1 -0
- package/dist/app.js +43 -0
- package/dist/app.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +4 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/derive.command.d.ts +5 -0
- package/dist/commands/derive.command.d.ts.map +1 -0
- package/dist/commands/derive.command.js +53 -0
- package/dist/commands/derive.command.js.map +1 -0
- package/dist/commands/git-attributes.command.d.ts +3 -0
- package/dist/commands/git-attributes.command.d.ts.map +1 -0
- package/dist/commands/git-attributes.command.js +12 -0
- package/dist/commands/git-attributes.command.js.map +1 -0
- package/dist/commands/read.command.d.ts +3 -0
- package/dist/commands/read.command.d.ts.map +1 -0
- package/dist/commands/read.command.js +126 -0
- package/dist/commands/read.command.js.map +1 -0
- package/dist/commands/sweep.command.d.ts +3 -0
- package/dist/commands/sweep.command.d.ts.map +1 -0
- package/dist/commands/sweep.command.js +41 -0
- package/dist/commands/sweep.command.js.map +1 -0
- package/dist/lib/archives.d.ts +18 -0
- package/dist/lib/archives.d.ts.map +1 -0
- package/dist/lib/archives.js +133 -0
- package/dist/lib/archives.js.map +1 -0
- package/dist/lib/derive.d.ts +32 -0
- package/dist/lib/derive.d.ts.map +1 -0
- package/dist/lib/derive.js +99 -0
- package/dist/lib/derive.js.map +1 -0
- package/dist/lib/derivers/archive.d.ts +3 -0
- package/dist/lib/derivers/archive.d.ts.map +1 -0
- package/dist/lib/derivers/archive.js +132 -0
- package/dist/lib/derivers/archive.js.map +1 -0
- package/dist/lib/derivers/deriver.d.ts +13 -0
- package/dist/lib/derivers/deriver.d.ts.map +1 -0
- package/dist/lib/derivers/deriver.js +8 -0
- package/dist/lib/derivers/deriver.js.map +1 -0
- package/dist/lib/derivers/docx.d.ts +6 -0
- package/dist/lib/derivers/docx.d.ts.map +1 -0
- package/dist/lib/derivers/docx.js +29 -0
- package/dist/lib/derivers/docx.js.map +1 -0
- package/dist/lib/derivers/epub.d.ts +3 -0
- package/dist/lib/derivers/epub.d.ts.map +1 -0
- package/dist/lib/derivers/epub.js +102 -0
- package/dist/lib/derivers/epub.js.map +1 -0
- package/dist/lib/derivers/html.d.ts +3 -0
- package/dist/lib/derivers/html.d.ts.map +1 -0
- package/dist/lib/derivers/html.js +18 -0
- package/dist/lib/derivers/html.js.map +1 -0
- package/dist/lib/derivers/image.d.ts +3 -0
- package/dist/lib/derivers/image.d.ts.map +1 -0
- package/dist/lib/derivers/image.js +42 -0
- package/dist/lib/derivers/image.js.map +1 -0
- package/dist/lib/derivers/ipynb.d.ts +3 -0
- package/dist/lib/derivers/ipynb.d.ts.map +1 -0
- package/dist/lib/derivers/ipynb.js +75 -0
- package/dist/lib/derivers/ipynb.js.map +1 -0
- package/dist/lib/derivers/media.d.ts +3 -0
- package/dist/lib/derivers/media.d.ts.map +1 -0
- package/dist/lib/derivers/media.js +45 -0
- package/dist/lib/derivers/media.js.map +1 -0
- package/dist/lib/derivers/odp.d.ts +3 -0
- package/dist/lib/derivers/odp.d.ts.map +1 -0
- package/dist/lib/derivers/odp.js +41 -0
- package/dist/lib/derivers/odp.js.map +1 -0
- package/dist/lib/derivers/ods.d.ts +3 -0
- package/dist/lib/derivers/ods.d.ts.map +1 -0
- package/dist/lib/derivers/ods.js +86 -0
- package/dist/lib/derivers/ods.js.map +1 -0
- package/dist/lib/derivers/odt.d.ts +4 -0
- package/dist/lib/derivers/odt.d.ts.map +1 -0
- package/dist/lib/derivers/odt.js +77 -0
- package/dist/lib/derivers/odt.js.map +1 -0
- package/dist/lib/derivers/pdf-furniture.d.ts +7 -0
- package/dist/lib/derivers/pdf-furniture.d.ts.map +1 -0
- package/dist/lib/derivers/pdf-furniture.js +76 -0
- package/dist/lib/derivers/pdf-furniture.js.map +1 -0
- package/dist/lib/derivers/pdf.d.ts +4 -0
- package/dist/lib/derivers/pdf.d.ts.map +1 -0
- package/dist/lib/derivers/pdf.js +79 -0
- package/dist/lib/derivers/pdf.js.map +1 -0
- package/dist/lib/derivers/pptx.d.ts +4 -0
- package/dist/lib/derivers/pptx.d.ts.map +1 -0
- package/dist/lib/derivers/pptx.js +51 -0
- package/dist/lib/derivers/pptx.js.map +1 -0
- package/dist/lib/derivers/profile.d.ts +3 -0
- package/dist/lib/derivers/profile.d.ts.map +1 -0
- package/dist/lib/derivers/profile.js +115 -0
- package/dist/lib/derivers/profile.js.map +1 -0
- package/dist/lib/derivers/rtf.d.ts +4 -0
- package/dist/lib/derivers/rtf.d.ts.map +1 -0
- package/dist/lib/derivers/rtf.js +187 -0
- package/dist/lib/derivers/rtf.js.map +1 -0
- package/dist/lib/derivers/xlsx.d.ts +3 -0
- package/dist/lib/derivers/xlsx.d.ts.map +1 -0
- package/dist/lib/derivers/xlsx.js +68 -0
- package/dist/lib/derivers/xlsx.js.map +1 -0
- package/dist/lib/env.d.ts +2 -0
- package/dist/lib/env.d.ts.map +1 -0
- package/dist/lib/env.js +5 -0
- package/dist/lib/env.js.map +1 -0
- package/dist/lib/flags.d.ts +2 -0
- package/dist/lib/flags.d.ts.map +1 -0
- package/dist/lib/flags.js +8 -0
- package/dist/lib/flags.js.map +1 -0
- package/dist/lib/formats.d.ts +6 -0
- package/dist/lib/formats.d.ts.map +1 -0
- package/dist/lib/formats.js +110 -0
- package/dist/lib/formats.js.map +1 -0
- package/dist/lib/sidecar.d.ts +30 -0
- package/dist/lib/sidecar.d.ts.map +1 -0
- package/dist/lib/sidecar.js +108 -0
- package/dist/lib/sidecar.js.map +1 -0
- package/dist/lib/sweep.d.ts +7 -0
- package/dist/lib/sweep.d.ts.map +1 -0
- package/dist/lib/sweep.js +56 -0
- package/dist/lib/sweep.js.map +1 -0
- package/dist/lib/tools.d.ts +2 -0
- package/dist/lib/tools.d.ts.map +1 -0
- package/dist/lib/tools.js +15 -0
- package/dist/lib/tools.js.map +1 -0
- package/dist/lib/xml.d.ts +3 -0
- package/dist/lib/xml.d.ts.map +1 -0
- package/dist/lib/xml.js +16 -0
- package/dist/lib/xml.js.map +1 -0
- package/dist/skill.d.ts +5 -0
- package/dist/skill.d.ts.map +1 -0
- package/dist/skill.js +75 -0
- package/dist/skill.js.map +1 -0
- package/dist/testing.d.ts +25 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +220 -0
- package/dist/testing.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +3 -0
- package/dist/version.js.map +1 -0
- package/package.json +89 -0
- package/src/app.ts +48 -0
- package/src/cli.integration.test.ts +200 -0
- package/src/cli.ts +6 -0
- package/src/commands/derive.command.ts +59 -0
- package/src/commands/git-attributes.command.ts +13 -0
- package/src/commands/read.command.ts +158 -0
- package/src/commands/sweep.command.ts +48 -0
- package/src/derivers.integration.test.ts +415 -0
- package/src/lib/archives.test.ts +72 -0
- package/src/lib/archives.ts +209 -0
- package/src/lib/derive.ts +132 -0
- package/src/lib/derivers/archive.ts +168 -0
- package/src/lib/derivers/deriver.ts +33 -0
- package/src/lib/derivers/docx.test.ts +38 -0
- package/src/lib/derivers/docx.ts +44 -0
- package/src/lib/derivers/epub.ts +130 -0
- package/src/lib/derivers/html.ts +21 -0
- package/src/lib/derivers/image.ts +54 -0
- package/src/lib/derivers/ipynb.ts +113 -0
- package/src/lib/derivers/load-cost.test.ts +44 -0
- package/src/lib/derivers/media.ts +51 -0
- package/src/lib/derivers/odp.ts +52 -0
- package/src/lib/derivers/ods.ts +104 -0
- package/src/lib/derivers/odt.ts +101 -0
- package/src/lib/derivers/pdf-furniture.test.ts +60 -0
- package/src/lib/derivers/pdf-furniture.ts +121 -0
- package/src/lib/derivers/pdf.ts +102 -0
- package/src/lib/derivers/pptx.ts +61 -0
- package/src/lib/derivers/profile.ts +172 -0
- package/src/lib/derivers/rtf.ts +233 -0
- package/src/lib/derivers/xlsx.ts +82 -0
- package/src/lib/env.ts +7 -0
- package/src/lib/formats.test.ts +50 -0
- package/src/lib/formats.ts +141 -0
- package/src/lib/sidecar.test.ts +60 -0
- package/src/lib/sidecar.ts +156 -0
- package/src/lib/sweep.ts +72 -0
- package/src/lib/tools.ts +16 -0
- package/src/lib/xml.ts +20 -0
- package/src/skill.ts +90 -0
- package/src/testing.ts +333 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { unzipSync } from "fflate";
|
|
3
|
+
import { bodyOf, parseHtml } from "@intentic/webq/dom";
|
|
4
|
+
import { renderMarkdown } from "@intentic/webq/markdown";
|
|
5
|
+
import type { DerivedDoc, Deriver } from "./deriver.js";
|
|
6
|
+
import { attributeOf, decodeEntities } from "../xml.js";
|
|
7
|
+
|
|
8
|
+
// ODF's content.xml is rewritten tag-for-tag into HTML and handed to webq's writer, the same road docx takes through
|
|
9
|
+
// mammoth, so an .odt and .docx of the same letter render the same markdown.
|
|
10
|
+
// A tag-name substitution over well-formed XML, no parser needed to rename elements; every namespaced tag the table
|
|
11
|
+
// doesn't name is unwrapped, keeping its text but dropping the tag.
|
|
12
|
+
|
|
13
|
+
// ODF element to HTML element; an empty value is a void marker to drop, since its text is nothing.
|
|
14
|
+
const TAG: Record<string, string> = {
|
|
15
|
+
"text:p": "p",
|
|
16
|
+
"text:list": "ul",
|
|
17
|
+
"text:list-item": "li",
|
|
18
|
+
"text:list-header": "li",
|
|
19
|
+
"text:span": "span",
|
|
20
|
+
"text:a": "a",
|
|
21
|
+
"text:line-break": "br",
|
|
22
|
+
"text:note-citation": "sup",
|
|
23
|
+
"table:table": "table",
|
|
24
|
+
"table:table-header-rows": "thead",
|
|
25
|
+
"table:table-row": "tr",
|
|
26
|
+
"table:table-cell": "td",
|
|
27
|
+
"table:covered-table-cell": "td", // a merged-away cell: an empty td keeps the columns aligned
|
|
28
|
+
"table:table-column": "col",
|
|
29
|
+
"table:table-columns": "colgroup",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// Subtrees that aren't the document's text: comments, tracked changes, drawing metadata, TOC machinery.
|
|
33
|
+
const DROP_SUBTREE = [
|
|
34
|
+
"office:annotation",
|
|
35
|
+
"text:tracked-changes",
|
|
36
|
+
"draw:frame",
|
|
37
|
+
"text:table-of-content-source",
|
|
38
|
+
"text:index-title-template",
|
|
39
|
+
"text:alphabetical-index-source",
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
const dropSubtrees = (xml: string): string =>
|
|
43
|
+
DROP_SUBTREE.reduce((text, tag) => text.replaceAll(new RegExp(`<${tag}\\b[^>]*?(?:/>|>[\\s\\S]*?</${tag}>)`, "g"), ""), xml);
|
|
44
|
+
|
|
45
|
+
/** content.xml's <office:text> body as the HTML webq's writer understands; exported for tests. */
|
|
46
|
+
export const odfToHtml = (xml: string): string => {
|
|
47
|
+
const start = xml.indexOf("<office:text");
|
|
48
|
+
const end = xml.lastIndexOf("</office:text>");
|
|
49
|
+
const body = dropSubtrees(start === -1 || end === -1 ? xml : xml.slice(start, end));
|
|
50
|
+
return (
|
|
51
|
+
body
|
|
52
|
+
// Headings carry level as an attribute, so tag and level are rewritten together (headings never nest).
|
|
53
|
+
.replaceAll(/<text:h\b([^>]*)>([\s\S]*?)<\/text:h>/g, (_, attributes: string, inner: string) => {
|
|
54
|
+
const level = Math.min(6, Math.max(1, Number(attributeOf(attributes, "text:outline-level") ?? "1") || 1));
|
|
55
|
+
return `<h${level}>${inner}</h${level}>`;
|
|
56
|
+
})
|
|
57
|
+
// ODF writes runs of spaces/tabs as elements, since XML would otherwise collapse them.
|
|
58
|
+
.replaceAll(/<text:s\b([^>]*)\/>/g, (_, attributes: string) => " ".repeat(Number(attributeOf(attributes, "text:c") ?? "1") || 1))
|
|
59
|
+
.replaceAll(/<text:tab\b[^>]*\/>/g, " ")
|
|
60
|
+
// Namespaced tags: mapped ones become their HTML, the rest are unwrapped.
|
|
61
|
+
.replaceAll(
|
|
62
|
+
/<(\/?)([a-zA-Z0-9]+:[a-zA-Z0-9-]+)((?:\s[^>]*?)?)(\/?)>/g,
|
|
63
|
+
(_, close: string, name: string, attributes: string, selfClosing: string) => {
|
|
64
|
+
const mapped = TAG[name];
|
|
65
|
+
if (mapped === undefined) {
|
|
66
|
+
return "";
|
|
67
|
+
}
|
|
68
|
+
if (close === "/") {
|
|
69
|
+
return `</${mapped}>`;
|
|
70
|
+
}
|
|
71
|
+
if (mapped === "a") {
|
|
72
|
+
const href = attributeOf(attributes, "xlink:href");
|
|
73
|
+
return href === undefined ? "<a>" : `<a href="${href.replaceAll('"', """)}">`;
|
|
74
|
+
}
|
|
75
|
+
return selfClosing === "/" && mapped !== "br" && mapped !== "col" ? `<${mapped}></${mapped}>` : `<${mapped}>`;
|
|
76
|
+
},
|
|
77
|
+
)
|
|
78
|
+
// A list item's single paragraph is unwrapped, since <p> inside <li> would render as bullet then text
|
|
79
|
+
// below.
|
|
80
|
+
.replaceAll(/<li>\s*<p>/g, "<li>")
|
|
81
|
+
.replaceAll(/<\/p>\s*<\/li>/g, "</li>")
|
|
82
|
+
);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export const odtDeriver: Deriver = {
|
|
86
|
+
name: "odt",
|
|
87
|
+
version: 1,
|
|
88
|
+
derive: async (absPath): Promise<DerivedDoc> => {
|
|
89
|
+
const zip = unzipSync(new Uint8Array(await readFile(absPath)));
|
|
90
|
+
const decoder = new TextDecoder();
|
|
91
|
+
const content = zip["content.xml"];
|
|
92
|
+
if (content === undefined) {
|
|
93
|
+
return { markdown: "", notes: ["no content.xml in this OpenDocument container"] };
|
|
94
|
+
}
|
|
95
|
+
const meta = zip["meta.xml"];
|
|
96
|
+
const titleMatch = meta === undefined ? null : /<dc:title>([\s\S]*?)<\/dc:title>/.exec(decoder.decode(meta));
|
|
97
|
+
const title = titleMatch?.[1] === undefined || titleMatch[1].trim() === "" ? undefined : decodeEntities(titleMatch[1].trim());
|
|
98
|
+
const body = bodyOf(parseHtml(`<html><body>${odfToHtml(decoder.decode(content))}</body></html>`));
|
|
99
|
+
return { markdown: body === undefined ? "" : renderMarkdown(body), title, notes: [] };
|
|
100
|
+
},
|
|
101
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { stripRunningFurniture } from "./pdf-furniture.js";
|
|
2
|
+
|
|
3
|
+
const WORDS = ["", "alpha", "beta", "gamma", "delta", "epsilon"];
|
|
4
|
+
|
|
5
|
+
// Page n of a printed RFC: body text between the header and footer it repeats on every page.
|
|
6
|
+
const page = (n: number, body: string): string =>
|
|
7
|
+
[
|
|
8
|
+
"RFC 9110 HTTP Semantics June 2022",
|
|
9
|
+
body,
|
|
10
|
+
`More of the ${WORDS[n]} part.`,
|
|
11
|
+
`The last line of ${WORDS[n]}.`,
|
|
12
|
+
`Fielding, et al. Standards Track [Page ${n}]`,
|
|
13
|
+
].join("\n");
|
|
14
|
+
|
|
15
|
+
describe("stripRunningFurniture", () => {
|
|
16
|
+
test("removes a running header and a numbered footer, keeping every body line", () => {
|
|
17
|
+
const pages = [1, 2, 3, 4, 5].map((n) => page(n, `Body of ${WORDS[n]}.`));
|
|
18
|
+
const { pages: kept, removed, example } = stripRunningFurniture(pages);
|
|
19
|
+
expect(removed).toBe(10);
|
|
20
|
+
expect(example).toBe("RFC 9110 HTTP Semantics June 2022");
|
|
21
|
+
expect(kept[2]).toBe("Body of gamma.\nMore of the gamma part.\nThe last line of gamma.");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("removes bare page numbers only when they count with the pages", () => {
|
|
25
|
+
const counted = ["alpha", "beta", "gamma", "delta"].map((word, index) => `${word} one\n${word} two\n${word} three\n${index + 3}`);
|
|
26
|
+
expect(stripRunningFurniture(counted).pages[0]).toBe("alpha one\nalpha two\nalpha three");
|
|
27
|
+
// A table whose last value happens to sit at the page edge is data, not a folio.
|
|
28
|
+
const values = ["alpha", "beta", "gamma", "delta"].map((word, index) => `${word} one\n${word} two\n${word} three\n${[42, 17, 42, 9][index]}`);
|
|
29
|
+
expect(stripRunningFurniture(values).removed).toBe(0);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("removes self-describing page numbers even where nothing else recurs", () => {
|
|
33
|
+
const pages = ["a", "b", "c", "d"].map((word, index) => `${word} first\n${word} middle\n${word} last\n- ${index + 1} -`);
|
|
34
|
+
expect(stripRunningFurniture(pages).pages.every((text) => !text.includes("-"))).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("leaves short documents and non-recurring edge lines alone", () => {
|
|
38
|
+
const short = [1, 2, 3].map((n) => page(n, "body"));
|
|
39
|
+
expect(stripRunningFurniture(short)).toEqual({ pages: short, removed: 0, example: undefined });
|
|
40
|
+
const distinct = ["One", "Two", "Three", "Four"].map((word) => `${word} heading\n${word} body\n${word} more\nclosing ${word}`);
|
|
41
|
+
expect(stripRunningFurniture(distinct).removed).toBe(0);
|
|
42
|
+
// The same line at the top of some pages and the bottom of others is running at neither edge.
|
|
43
|
+
const wandering = ["a", "b", "c", "d"].map((word, index) =>
|
|
44
|
+
index % 2 === 0 ? `See also\n${word} x\n${word} y\n${word} z` : `${word} x\n${word} y\n${word} z\nSee also`,
|
|
45
|
+
);
|
|
46
|
+
expect(stripRunningFurniture(wandering).removed).toBe(0);
|
|
47
|
+
// Numbered content that does not count with the pages is not a running line.
|
|
48
|
+
const questions = [3, 7, 8, 12].map((n) => `Question ${n}\nWhat is ${n} squared?\nShow ${n} steps.\nAnswer ${n}: ____`);
|
|
49
|
+
expect(stripRunningFurniture(questions).removed).toBe(0);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("a page holding only furniture comes back empty, and the others keep their places", () => {
|
|
53
|
+
const pages = [1, 2, 3, 4, 5].map((n) => page(n, `Body of ${WORDS[n]}.`));
|
|
54
|
+
pages[3] = "RFC 9110 HTTP Semantics June 2022\nFielding, et al. Standards Track [Page 4]";
|
|
55
|
+
const { pages: kept } = stripRunningFurniture(pages);
|
|
56
|
+
expect(kept).toHaveLength(5);
|
|
57
|
+
expect(kept[3]).toBe("");
|
|
58
|
+
expect(kept[4]).toBe("Body of epsilon.\nMore of the epsilon part.\nThe last line of epsilon.");
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// Running headers, footers and page numbers: the lines a printed document repeats on every page, which cost tokens on
|
|
2
|
+
// each one and split the text they interrupt. Adapted from docurip's PDF text cleaner (MIT,
|
|
3
|
+
// https://github.com/MokuDev/docurip), keeping only its structural rules. Its footnote and "copyright"-line removal
|
|
4
|
+
// delete content, so they are left out.
|
|
5
|
+
// A line is furniture when it sits near a page edge AND recurs at that same edge on most pages (verbatim, or with its
|
|
6
|
+
// numbers counting with the pages), or it is a page number. Content is never judged by what it says.
|
|
7
|
+
|
|
8
|
+
// How many non-blank lines at each edge of a page can be furniture.
|
|
9
|
+
const EDGE_LINES = 3;
|
|
10
|
+
// Share of pages an edge line must recur on. Below MIN_PAGES there is too little evidence to call anything running.
|
|
11
|
+
const RECUR_SHARE = 0.6;
|
|
12
|
+
const MIN_PAGES = 4;
|
|
13
|
+
|
|
14
|
+
// A bare number is only a page number when it counts with the pages (see folioOffset); these shapes say so themselves.
|
|
15
|
+
const PAGE_NUMBER = [
|
|
16
|
+
/^page\s+\d{1,4}(?:\s+of\s+\d{1,4})?$/i, // Page 7, Page 7 of 90
|
|
17
|
+
/^\d{1,4}\s*(?:of|\/)\s*\d{1,4}$/i, // 7 of 90, 7/90
|
|
18
|
+
/^[-–—]\s*\d{1,4}\s*[-–—]$/, // - 7 -
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
export interface Destripped {
|
|
22
|
+
readonly pages: string[];
|
|
23
|
+
/** Lines removed, summed over every page. */
|
|
24
|
+
readonly removed: number;
|
|
25
|
+
/** The most frequent running line, for the note: a reader should see what was taken. */
|
|
26
|
+
readonly example: string | undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Two signatures per line: its exact text (a title, a date that never changes), and its text with every number
|
|
30
|
+
// rewritten as its offset from the page index, so "Page 7" on the seventh page and "Page 8" on the eighth agree.
|
|
31
|
+
// Blanking digits outright would also match "Question 3" and "Question 4", which are content.
|
|
32
|
+
const signaturesOf = (line: string, page: number): string[] => {
|
|
33
|
+
const exact = line.replaceAll(/\s+/g, " ").trim().toLowerCase();
|
|
34
|
+
return [`=${exact}`, `~${exact.replaceAll(/\d+/g, (n) => `#${Number(n) - page}`)}`];
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
interface Edges {
|
|
38
|
+
readonly top: number[];
|
|
39
|
+
readonly bottom: number[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const edgesOf = (lines: readonly string[]): Edges => {
|
|
43
|
+
const filled = lines.flatMap((line, index) => (line.trim() === "" ? [] : [index]));
|
|
44
|
+
// At most half a page's lines sit in each edge zone, so a line is a header or a footer, never both.
|
|
45
|
+
const edge = Math.min(EDGE_LINES, Math.floor(filled.length / 2));
|
|
46
|
+
return edge === 0 ? { top: [], bottom: [] } : { top: filled.slice(0, edge), bottom: filled.slice(-edge) };
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// Headers and footers are counted apart: a line is running only where it recurs at the same edge.
|
|
50
|
+
const recurringAt = (split: readonly string[][], side: keyof Edges, needed: number): Set<string> => {
|
|
51
|
+
const recurrence = new Map<string, number>();
|
|
52
|
+
for (const [page, lines] of split.entries()) {
|
|
53
|
+
// A signature counts once per page, however many edge lines carry it.
|
|
54
|
+
for (const signature of new Set(edgesOf(lines)[side].flatMap((index) => signaturesOf(lines[index] ?? "", page)))) {
|
|
55
|
+
recurrence.set(signature, (recurrence.get(signature) ?? 0) + 1);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// A bare number is folioOffset's call, so a data column's repeated value is not caught here.
|
|
59
|
+
return new Set(
|
|
60
|
+
[...recurrence].filter(([signature, count]) => count >= needed && !/^[=~]#?-?\d+$/.test(signature)).map(([signature]) => signature),
|
|
61
|
+
);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const BARE_NUMBER = /^\d{1,4}$/;
|
|
65
|
+
|
|
66
|
+
// The offset between page index and printed number that most pages' bare edge numbers agree on, when enough do.
|
|
67
|
+
// A table's last value at a page edge does not count with the pages, so it never matches.
|
|
68
|
+
const folioOffset = (split: readonly string[][], needed: number): number | undefined => {
|
|
69
|
+
const votes = new Map<number, number>();
|
|
70
|
+
split.forEach((lines, page) => {
|
|
71
|
+
const offsets = new Set(
|
|
72
|
+
[...edgesOf(lines).top, ...edgesOf(lines).bottom]
|
|
73
|
+
.map((index) => (lines[index] ?? "").trim())
|
|
74
|
+
.filter((line) => BARE_NUMBER.test(line))
|
|
75
|
+
.map((line) => Number(line) - page),
|
|
76
|
+
);
|
|
77
|
+
for (const offset of offsets) {
|
|
78
|
+
votes.set(offset, (votes.get(offset) ?? 0) + 1);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
const [offset, count] = [...votes].toSorted((a, b) => b[1] - a[1])[0] ?? [0, 0];
|
|
82
|
+
return count >= needed ? offset : undefined;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/** Strips running headers, footers and page numbers from per-page text. Pages keep their count and order. */
|
|
86
|
+
export const stripRunningFurniture = (pages: readonly string[]): Destripped => {
|
|
87
|
+
if (pages.length < MIN_PAGES) {
|
|
88
|
+
return { pages: [...pages], removed: 0, example: undefined };
|
|
89
|
+
}
|
|
90
|
+
const split = pages.map((page) => page.split("\n"));
|
|
91
|
+
const needed = Math.ceil(pages.length * RECUR_SHARE);
|
|
92
|
+
const headers = recurringAt(split, "top", needed);
|
|
93
|
+
const footers = recurringAt(split, "bottom", needed);
|
|
94
|
+
const offset = folioOffset(split, needed);
|
|
95
|
+
const seen = new Map<string, number>();
|
|
96
|
+
let removed = 0;
|
|
97
|
+
const out = split.map((lines, page) => {
|
|
98
|
+
const { top, bottom } = edgesOf(lines);
|
|
99
|
+
const furniture =
|
|
100
|
+
(running: Set<string>) =>
|
|
101
|
+
(index: number): boolean => {
|
|
102
|
+
const line = (lines[index] ?? "").trim();
|
|
103
|
+
const folio = offset !== undefined && BARE_NUMBER.test(line) && Number(line) - page === offset;
|
|
104
|
+
return folio || signaturesOf(line, page).some((signature) => running.has(signature)) || PAGE_NUMBER.some((shape) => shape.test(line));
|
|
105
|
+
};
|
|
106
|
+
const drop = new Set([...top.filter(furniture(headers)), ...bottom.filter(furniture(footers))]);
|
|
107
|
+
for (const index of drop) {
|
|
108
|
+
const line = (lines[index] ?? "").trim();
|
|
109
|
+
if (!/^\d+$/.test(line)) {
|
|
110
|
+
seen.set(line, (seen.get(line) ?? 0) + 1);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
removed += drop.size;
|
|
114
|
+
return lines
|
|
115
|
+
.filter((_, index) => !drop.has(index))
|
|
116
|
+
.join("\n")
|
|
117
|
+
.trim();
|
|
118
|
+
});
|
|
119
|
+
const example = [...seen].toSorted((a, b) => b[1] - a[1])[0]?.[0];
|
|
120
|
+
return { pages: out, removed, example };
|
|
121
|
+
};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { mkdtemp, readdir, readFile, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { extractText, getDocumentProxy, getMeta } from "unpdf";
|
|
7
|
+
import { onPath } from "../tools.js";
|
|
8
|
+
import type { DerivedDoc, Deriver } from "./deriver.js";
|
|
9
|
+
import { stripRunningFurniture } from "./pdf-furniture.js";
|
|
10
|
+
|
|
11
|
+
// PDF text layer, per page, via unpdf's serverless pdf.js build (no worker, canvas or DOM shims needed for text
|
|
12
|
+
// extraction).
|
|
13
|
+
// A scan with no text layer is rasterised and OCR'd when tesseract is on the image, noted as recognised rather than
|
|
14
|
+
// exact; otherwise the sidecar says so instead of an empty page.
|
|
15
|
+
// Either way, the deterministic tier never guesses at pixels.
|
|
16
|
+
|
|
17
|
+
// Below this many chars per page, the text layer is furniture, not content: a scan with a vestigial layer.
|
|
18
|
+
const SCAN_THRESHOLD_CHARS_PER_PAGE = 24;
|
|
19
|
+
|
|
20
|
+
// OCR costs seconds per page and runs unasked in the sweep, so a long scan gets its first pages and a note.
|
|
21
|
+
const MAX_OCR_PAGES = 20;
|
|
22
|
+
const OCR_DPI = "200";
|
|
23
|
+
|
|
24
|
+
const run = promisify(execFile);
|
|
25
|
+
|
|
26
|
+
/** Whether this image can recognise a scan: both the rasteriser and the recogniser on PATH. */
|
|
27
|
+
export const ocrAvailable = (): boolean => onPath("tesseract") && onPath("pdftoppm");
|
|
28
|
+
|
|
29
|
+
const recognisePages = async (absPath: string, totalPages: number): Promise<string[]> => {
|
|
30
|
+
const dir = await mkdtemp(join(tmpdir(), "fileq-ocr-"));
|
|
31
|
+
try {
|
|
32
|
+
await run("pdftoppm", ["-r", OCR_DPI, "-png", "-f", "1", "-l", String(Math.min(totalPages, MAX_OCR_PAGES)), absPath, join(dir, "page")], {
|
|
33
|
+
timeout: 120_000,
|
|
34
|
+
});
|
|
35
|
+
const images = (await readdir(dir)).filter((name) => name.endsWith(".png")).toSorted();
|
|
36
|
+
const pages: string[] = [];
|
|
37
|
+
for (const image of images) {
|
|
38
|
+
const { stdout } = await run("tesseract", [join(dir, image), "stdout", "-l", "eng"], { timeout: 120_000, maxBuffer: 16 * 1024 * 1024 });
|
|
39
|
+
pages.push(stdout.replaceAll(/[ \t]+/g, " ").trim());
|
|
40
|
+
}
|
|
41
|
+
return pages;
|
|
42
|
+
} finally {
|
|
43
|
+
await rm(dir, { recursive: true, force: true });
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const pagesToMarkdown = (pages: readonly string[]): string =>
|
|
48
|
+
pages.length === 1
|
|
49
|
+
? (pages[0] ?? "")
|
|
50
|
+
: pages.map((page, index) => `## Page ${index + 1}\n\n${page === "" ? "(no text on this page)" : page}`).join("\n\n");
|
|
51
|
+
|
|
52
|
+
// Every stripped line is named in a note, since a reader comparing against the page image should know what went.
|
|
53
|
+
const furnitureNotes = (removed: number, example: string | undefined): string[] =>
|
|
54
|
+
removed === 0
|
|
55
|
+
? []
|
|
56
|
+
: [
|
|
57
|
+
`removed ${removed} running header, footer and page-number line${removed === 1 ? "" : "s"}${example === undefined ? "" : ` (such as "${example}")`}`,
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
const withoutFurniture = (pages: readonly string[]): { markdown: string; notes: string[] } => {
|
|
61
|
+
const { pages: kept, removed, example } = stripRunningFurniture(pages);
|
|
62
|
+
return { markdown: pagesToMarkdown(kept), notes: furnitureNotes(removed, example) };
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export const pdfDeriver: Deriver = {
|
|
66
|
+
// The stamp names OCR capability, since a sidecar written before tesseract was available must read as stale now
|
|
67
|
+
// that it's here.
|
|
68
|
+
// A static name would leave a pre-OCR sidecar looking current forever.
|
|
69
|
+
get name(): string {
|
|
70
|
+
return ocrAvailable() ? "pdf+ocr" : "pdf";
|
|
71
|
+
},
|
|
72
|
+
version: 2,
|
|
73
|
+
derive: async (absPath): Promise<DerivedDoc> => {
|
|
74
|
+
const pdf = await getDocumentProxy(new Uint8Array(await readFile(absPath)));
|
|
75
|
+
const { totalPages, text } = await extractText(pdf, { mergePages: false });
|
|
76
|
+
const meta = await getMeta(pdf).catch(() => undefined);
|
|
77
|
+
const info = meta?.info as Record<string, unknown> | undefined;
|
|
78
|
+
const title = typeof info?.["Title"] === "string" && info["Title"] !== "" ? info["Title"] : undefined;
|
|
79
|
+
const pages = text.map((page) => page.replaceAll(/[ \t]+/g, " ").trim());
|
|
80
|
+
const totalChars = pages.reduce((sum, page) => sum + page.length, 0);
|
|
81
|
+
if (totalChars < SCAN_THRESHOLD_CHARS_PER_PAGE * totalPages) {
|
|
82
|
+
const plural = totalPages === 1 ? "" : "s";
|
|
83
|
+
if (!ocrAvailable()) {
|
|
84
|
+
return {
|
|
85
|
+
markdown: "",
|
|
86
|
+
title,
|
|
87
|
+
notes: [`no usable text layer across ${totalPages} page${plural} (a scan?): OCR is not part of this tier`],
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
const recognised = await recognisePages(absPath, totalPages);
|
|
91
|
+
const notes = [
|
|
92
|
+
`scanned: text recognised by OCR (tesseract) on ${recognised.length} of ${totalPages} page${plural} — recognised, not exact; check figures against the page image`,
|
|
93
|
+
];
|
|
94
|
+
if (totalPages > MAX_OCR_PAGES) {
|
|
95
|
+
notes.push(`OCR stops at ${MAX_OCR_PAGES} pages: run tesseract over the rest yourself if they matter`);
|
|
96
|
+
}
|
|
97
|
+
const { markdown, notes: stripped } = withoutFurniture(recognised);
|
|
98
|
+
return { markdown, title, notes: [...notes, ...stripped] };
|
|
99
|
+
}
|
|
100
|
+
return { title, ...withoutFurniture(pages) };
|
|
101
|
+
},
|
|
102
|
+
};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { unzipSync } from "fflate";
|
|
3
|
+
import type { DerivedDoc, Deriver } from "./deriver.js";
|
|
4
|
+
import { decodeEntities } from "../xml.js";
|
|
5
|
+
|
|
6
|
+
/* Presentations: text runs out of the OOXML slide parts, one `## Slide N` section each. */
|
|
7
|
+
|
|
8
|
+
const SLIDE_PART = /^ppt\/slides\/slide(\d+)\.xml$/;
|
|
9
|
+
const NOTES_PART = /^ppt\/notesSlides\/notesSlide(\d+)\.xml$/;
|
|
10
|
+
|
|
11
|
+
// One slide part's XML → its paragraphs: `<a:t>` runs joined within each `<a:p>`, empties dropped.
|
|
12
|
+
export const slideParagraphs = (xml: string): string[] =>
|
|
13
|
+
[...xml.matchAll(/<a:p\b[\s\S]*?<\/a:p>/g)]
|
|
14
|
+
.map((paragraph) =>
|
|
15
|
+
[...paragraph[0].matchAll(/<a:t[^>]*>([\s\S]*?)<\/a:t>/g)]
|
|
16
|
+
.map((run) => decodeEntities(run[1] ?? ""))
|
|
17
|
+
.join("")
|
|
18
|
+
.trim(),
|
|
19
|
+
)
|
|
20
|
+
.filter((text) => text !== "");
|
|
21
|
+
|
|
22
|
+
const partsByNumber = (names: string[], pattern: RegExp): Map<number, string> => {
|
|
23
|
+
const map = new Map<number, string>();
|
|
24
|
+
for (const name of names) {
|
|
25
|
+
const match = pattern.exec(name);
|
|
26
|
+
if (match !== null) {
|
|
27
|
+
map.set(Number(match[1]), name);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return map;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const pptxDeriver: Deriver = {
|
|
34
|
+
name: "pptx",
|
|
35
|
+
version: 1,
|
|
36
|
+
derive: async (absPath): Promise<DerivedDoc> => {
|
|
37
|
+
const zip = unzipSync(new Uint8Array(await readFile(absPath)));
|
|
38
|
+
const names = Object.keys(zip);
|
|
39
|
+
const decoder = new TextDecoder();
|
|
40
|
+
const xmlOf = (name: string): string => decoder.decode(zip[name]);
|
|
41
|
+
const slides = partsByNumber(names, SLIDE_PART);
|
|
42
|
+
const notes = partsByNumber(names, NOTES_PART);
|
|
43
|
+
const sections: string[] = [];
|
|
44
|
+
for (const number of [...slides.keys()].toSorted((a, b) => a - b)) {
|
|
45
|
+
const lines = slideParagraphs(xmlOf(slides.get(number) ?? ""));
|
|
46
|
+
const section = [`## Slide ${number}`, ...lines];
|
|
47
|
+
const notesPart = notes.get(number);
|
|
48
|
+
if (notesPart !== undefined) {
|
|
49
|
+
const noteLines = slideParagraphs(xmlOf(notesPart)).filter((line) => !/^\d+$/.test(line)); // slide-number placeholder
|
|
50
|
+
if (noteLines.length > 0) {
|
|
51
|
+
section.push("", "Notes:", ...noteLines.map((line) => `> ${line}`));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
sections.push(section.join("\n"));
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
markdown: sections.join("\n\n"),
|
|
58
|
+
notes: slides.size === 0 ? ["no slide parts found in this presentation"] : [],
|
|
59
|
+
};
|
|
60
|
+
},
|
|
61
|
+
};
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { plural } from "@intentic/base/format";
|
|
3
|
+
import type { DerivedDoc, Deriver } from "./deriver.js";
|
|
4
|
+
|
|
5
|
+
/* V8 profiles (node --cpu-prof, --heap-prof, DevTools): a sample tree whose reading is a ranked table, not the JSON. */
|
|
6
|
+
|
|
7
|
+
const TOP_ROWS = 25;
|
|
8
|
+
|
|
9
|
+
interface CallFrame {
|
|
10
|
+
readonly functionName?: string;
|
|
11
|
+
readonly url?: string;
|
|
12
|
+
readonly lineNumber?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// --cpu-prof: a flat node list, each sample naming the node that was on top of the stack.
|
|
16
|
+
interface CpuNode {
|
|
17
|
+
readonly id: number;
|
|
18
|
+
readonly callFrame: CallFrame;
|
|
19
|
+
readonly children?: readonly number[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface CpuProfile {
|
|
23
|
+
readonly nodes: readonly CpuNode[];
|
|
24
|
+
readonly startTime: number;
|
|
25
|
+
readonly endTime: number;
|
|
26
|
+
readonly samples?: readonly number[];
|
|
27
|
+
readonly timeDeltas?: readonly number[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// --heap-prof: a tree of allocation sites, each with the bytes sampled as allocated there.
|
|
31
|
+
interface HeapNode {
|
|
32
|
+
readonly callFrame: CallFrame;
|
|
33
|
+
readonly selfSize: number;
|
|
34
|
+
readonly children?: readonly HeapNode[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface HeapProfile {
|
|
38
|
+
readonly head: HeapNode;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface Row {
|
|
42
|
+
readonly key: string;
|
|
43
|
+
self: number;
|
|
44
|
+
total: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// One function across every node it appears at: V8 splits a function into a node per distinct caller path.
|
|
48
|
+
const keyOf = (frame: CallFrame): string => {
|
|
49
|
+
const name = frame.functionName === undefined || frame.functionName === "" ? "(anonymous)" : frame.functionName;
|
|
50
|
+
if (frame.url === undefined || frame.url === "") {
|
|
51
|
+
return name;
|
|
52
|
+
}
|
|
53
|
+
// Line numbers are 0-based in the profile and 1-based everywhere a person or an editor reads them.
|
|
54
|
+
const file = frame.url.replace(/^file:\/\//u, "");
|
|
55
|
+
return `${name} ${file}:${String((frame.lineNumber ?? 0) + 1)}`;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const rowOf = (rows: Map<string, Row>, key: string): Row => {
|
|
59
|
+
const existing = rows.get(key);
|
|
60
|
+
if (existing !== undefined) {
|
|
61
|
+
return existing;
|
|
62
|
+
}
|
|
63
|
+
const row: Row = { key, self: 0, total: 0 };
|
|
64
|
+
rows.set(key, row);
|
|
65
|
+
return row;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const percent = (part: number, whole: number): string => (whole === 0 ? "0.0%" : `${((part / whole) * 100).toFixed(1)}%`);
|
|
69
|
+
|
|
70
|
+
const table = (heading: string, unit: (value: number) => string, rows: readonly Row[], whole: number): string =>
|
|
71
|
+
[
|
|
72
|
+
`## ${heading}`,
|
|
73
|
+
"",
|
|
74
|
+
"| self | self % | total | total % | function |",
|
|
75
|
+
"| ---: | ---: | ---: | ---: | --- |",
|
|
76
|
+
...rows.map((row) => `| ${unit(row.self)} | ${percent(row.self, whole)} | ${unit(row.total)} | ${percent(row.total, whole)} | \`${row.key.replaceAll("|", "\\|")}\` |`),
|
|
77
|
+
].join("\n");
|
|
78
|
+
|
|
79
|
+
const ranked = (rows: Map<string, Row>, by: "self" | "total"): readonly Row[] => [...rows.values()].toSorted((left, right) => right[by] - left[by]).slice(0, TOP_ROWS);
|
|
80
|
+
|
|
81
|
+
const milliseconds = (microseconds: number): string => `${(microseconds / 1_000).toFixed(1)} ms`;
|
|
82
|
+
|
|
83
|
+
const kilobytes = (bytes: number): string => `${(bytes / 1_024).toFixed(1)} KB`;
|
|
84
|
+
|
|
85
|
+
// Sample i's duration is the gap to the next sample; the last one takes the mean, since nothing follows it.
|
|
86
|
+
const cpuDoc = (profile: CpuProfile): DerivedDoc => {
|
|
87
|
+
const samples = profile.samples ?? [];
|
|
88
|
+
const deltas = profile.timeDeltas ?? [];
|
|
89
|
+
const parent = new Map<number, number>();
|
|
90
|
+
const byId = new Map<number, CpuNode>();
|
|
91
|
+
for (const node of profile.nodes) {
|
|
92
|
+
byId.set(node.id, node);
|
|
93
|
+
for (const child of node.children ?? []) {
|
|
94
|
+
parent.set(child, node.id);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const span = profile.endTime - profile.startTime;
|
|
98
|
+
const meanGap = samples.length === 0 ? 0 : span / samples.length;
|
|
99
|
+
const rows = new Map<string, Row>();
|
|
100
|
+
for (const [index, id] of samples.entries()) {
|
|
101
|
+
const duration = deltas[index + 1] ?? meanGap;
|
|
102
|
+
const top = byId.get(id);
|
|
103
|
+
if (top === undefined) {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
rowOf(rows, keyOf(top.callFrame)).self += duration;
|
|
107
|
+
// Inclusive time counts a function once per sample, however deep it recurses.
|
|
108
|
+
const onStack = new Set<string>();
|
|
109
|
+
for (let node: number | undefined = id; node !== undefined; node = parent.get(node)) {
|
|
110
|
+
const frame = byId.get(node)?.callFrame;
|
|
111
|
+
if (frame !== undefined && frame.functionName !== "(root)") {
|
|
112
|
+
onStack.add(keyOf(frame));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
for (const key of onStack) {
|
|
116
|
+
rowOf(rows, key).total += duration;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const notes = samples.length === 0 ? ["no samples in this profile: it recorded nothing, or ran shorter than one sampling interval"] : [];
|
|
120
|
+
if (rows.size > TOP_ROWS) {
|
|
121
|
+
notes.push(`${plural(rows.size, "function")} sampled; each table shows the top ${String(TOP_ROWS)}`);
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
title: `CPU profile: ${milliseconds(span)} over ${plural(samples.length, "sample")}`,
|
|
125
|
+
markdown: [table("By self time", milliseconds, ranked(rows, "self"), span), table("By total time", milliseconds, ranked(rows, "total"), span)].join("\n\n"),
|
|
126
|
+
notes,
|
|
127
|
+
};
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const heapDoc = (profile: HeapProfile): DerivedDoc => {
|
|
131
|
+
const rows = new Map<string, Row>();
|
|
132
|
+
// Returns the subtree's bytes; a function's total counts each path through it once, recursion included.
|
|
133
|
+
const walk = (node: HeapNode, ancestors: ReadonlySet<string>): number => {
|
|
134
|
+
const key = keyOf(node.callFrame);
|
|
135
|
+
const isRoot = node.callFrame.functionName === "(root)";
|
|
136
|
+
const inside = isRoot || ancestors.has(key) ? ancestors : new Set([...ancestors, key]);
|
|
137
|
+
const below = (node.children ?? []).reduce((sum, child) => sum + walk(child, inside), 0);
|
|
138
|
+
if (!isRoot) {
|
|
139
|
+
const row = rowOf(rows, key);
|
|
140
|
+
row.self += node.selfSize;
|
|
141
|
+
if (!ancestors.has(key)) {
|
|
142
|
+
row.total += node.selfSize + below;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return node.selfSize + below;
|
|
146
|
+
};
|
|
147
|
+
const whole = walk(profile.head, new Set());
|
|
148
|
+
const notes = whole === 0 ? ["no allocations sampled in this profile"] : [];
|
|
149
|
+
if (rows.size > TOP_ROWS) {
|
|
150
|
+
notes.push(`${plural(rows.size, "allocation site")} sampled; each table shows the top ${String(TOP_ROWS)}`);
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
title: `Heap profile: ${kilobytes(whole)} sampled as allocated`,
|
|
154
|
+
markdown: [table("By bytes allocated here", kilobytes, ranked(rows, "self"), whole), table("By bytes allocated here and below", kilobytes, ranked(rows, "total"), whole)].join("\n\n"),
|
|
155
|
+
notes,
|
|
156
|
+
};
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
export const profileDeriver: Deriver = {
|
|
160
|
+
name: "profile",
|
|
161
|
+
version: 1,
|
|
162
|
+
derive: async (absPath): Promise<DerivedDoc> => {
|
|
163
|
+
const profile = JSON.parse(await readFile(absPath, "utf8")) as Partial<CpuProfile & HeapProfile>;
|
|
164
|
+
if (Array.isArray(profile.nodes) && typeof profile.startTime === "number" && typeof profile.endTime === "number") {
|
|
165
|
+
return cpuDoc(profile as CpuProfile);
|
|
166
|
+
}
|
|
167
|
+
if (profile.head !== undefined) {
|
|
168
|
+
return heapDoc(profile as HeapProfile);
|
|
169
|
+
}
|
|
170
|
+
return { markdown: "", notes: ["not a V8 CPU or heap profile: neither `nodes` with start and end times, nor a `head` node"] };
|
|
171
|
+
},
|
|
172
|
+
};
|