strom-research 1.0.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 +373 -0
- package/README.md +142 -0
- package/assets/lang/cs.json +302 -0
- package/assets/lang/de.json +302 -0
- package/assets/method/core.md +43 -0
- package/assets/method/enrich.md +11 -0
- package/assets/method/intake.md +30 -0
- package/assets/method/link.md +28 -0
- package/assets/method/locate.md +28 -0
- package/assets/method/narrate.md +13 -0
- package/assets/method/reading.md +62 -0
- package/assets/method/recording.md +59 -0
- package/assets/method/request.md +10 -0
- package/assets/method/verify.md +17 -0
- package/assets/plugins/README.md +23 -0
- package/assets/plugins/connectors/DISCOVERY.md +159 -0
- package/assets/plugins/connectors/README.md +376 -0
- package/assets/plugins/connectors/sdk.ts +168 -0
- package/assets/plugins/connectors/template.ts +38 -0
- package/assets/plugins/gitignore +4 -0
- package/dist/agents/files.js +313 -0
- package/dist/agents/global.js +257 -0
- package/dist/agents/launch.js +36 -0
- package/dist/agents/profiles.js +95 -0
- package/dist/brief/brief.js +345 -0
- package/dist/cli/commit.js +44 -0
- package/dist/cli/context.js +311 -0
- package/dist/cli/execute.js +154 -0
- package/dist/cli/fixes.js +78 -0
- package/dist/cli/format.js +53 -0
- package/dist/cli/help.js +59 -0
- package/dist/cli/main.js +152 -0
- package/dist/cli/menu.js +212 -0
- package/dist/cli/registry.js +96 -0
- package/dist/cli/ui.js +266 -0
- package/dist/cli/wizard.js +142 -0
- package/dist/cli.js +14 -0
- package/dist/commands/analysis.js +622 -0
- package/dist/commands/batch.js +181 -0
- package/dist/commands/checks.js +153 -0
- package/dist/commands/connectors.js +1377 -0
- package/dist/commands/guide.js +160 -0
- package/dist/commands/index.js +19 -0
- package/dist/commands/intake.js +234 -0
- package/dist/commands/media.js +406 -0
- package/dist/commands/meta.js +195 -0
- package/dist/commands/output.js +117 -0
- package/dist/commands/people.js +664 -0
- package/dist/commands/read.js +199 -0
- package/dist/commands/research.js +139 -0
- package/dist/commands/session.js +605 -0
- package/dist/commands/setup.js +465 -0
- package/dist/commands/sources.js +634 -0
- package/dist/commands/start.js +383 -0
- package/dist/commands/story.js +75 -0
- package/dist/commands/tasks.js +436 -0
- package/dist/commands/trees.js +128 -0
- package/dist/core/actions.js +852 -0
- package/dist/core/age.js +95 -0
- package/dist/core/apps.js +74 -0
- package/dist/core/assets.js +34 -0
- package/dist/core/awake.js +33 -0
- package/dist/core/browser.js +281 -0
- package/dist/core/calibration.js +48 -0
- package/dist/core/check.js +112 -0
- package/dist/core/chromium.js +88 -0
- package/dist/core/config.js +348 -0
- package/dist/core/connector.js +811 -0
- package/dist/core/deps.js +73 -0
- package/dist/core/dialog.js +61 -0
- package/dist/core/errors.js +89 -0
- package/dist/core/evidence.js +58 -0
- package/dist/core/frontier.js +219 -0
- package/dist/core/gdate.js +77 -0
- package/dist/core/git.js +300 -0
- package/dist/core/guard.js +124 -0
- package/dist/core/http2.js +76 -0
- package/dist/core/import.js +541 -0
- package/dist/core/install.js +28 -0
- package/dist/core/integrity.js +219 -0
- package/dist/core/json.js +87 -0
- package/dist/core/lang.js +70 -0
- package/dist/core/live.js +244 -0
- package/dist/core/lock.js +112 -0
- package/dist/core/logins.js +67 -0
- package/dist/core/media.js +223 -0
- package/dist/core/model.js +101 -0
- package/dist/core/net.js +366 -0
- package/dist/core/open.js +29 -0
- package/dist/core/paths.js +84 -0
- package/dist/core/people.js +283 -0
- package/dist/core/phrases.js +85 -0
- package/dist/core/queue.js +113 -0
- package/dist/core/reader.js +76 -0
- package/dist/core/records.js +105 -0
- package/dist/core/roles.js +30 -0
- package/dist/core/schema.js +261 -0
- package/dist/core/seal.js +77 -0
- package/dist/core/self.js +40 -0
- package/dist/core/session.js +155 -0
- package/dist/core/shortcut.js +90 -0
- package/dist/core/stories.js +61 -0
- package/dist/core/stromapp.js +138 -0
- package/dist/core/text.js +104 -0
- package/dist/core/tree.js +507 -0
- package/dist/core/uninstall.js +128 -0
- package/dist/core/update.js +193 -0
- package/dist/core/validate.js +260 -0
- package/dist/core/views.js +164 -0
- package/dist/core/which.js +51 -0
- package/dist/core/workers.js +42 -0
- package/dist/gedcom/export.js +454 -0
- package/dist/gedcom/labels.js +103 -0
- package/dist/gedcom/lines.js +91 -0
- package/dist/gedcom/parse.js +53 -0
- package/dist/gedcom/validate.js +183 -0
- package/dist/image/image.js +223 -0
- package/dist/image/index.js +114 -0
- package/dist/image/jpeg-decode.js +552 -0
- package/dist/image/jpeg-encode.js +254 -0
- package/dist/image/png.js +241 -0
- package/dist/runners/antigravity.js +70 -0
- package/dist/runners/claude.js +179 -0
- package/dist/runners/codex.js +45 -0
- package/dist/runners/index.js +13 -0
- package/dist/runners/jsonl.js +86 -0
- package/dist/runners/opencode.js +50 -0
- package/dist/runners/runner.js +63 -0
- package/dist/runners/script.js +58 -0
- package/package.json +44 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// GEDCOM line writing: levels, the 255-byte limit, CONC/CONT splitting.
|
|
2
|
+
//
|
|
3
|
+
// CONC continues the same line (joined with nothing), CONT starts a new line.
|
|
4
|
+
// Splits never sit next to a space: a value that begins or ends with a space
|
|
5
|
+
// gets trimmed by some readers and two words silently glue together.
|
|
6
|
+
export const MAX_LINE_BYTES = 255;
|
|
7
|
+
/** Tags whose value may be a pointer (@X@) — everywhere else an "@" is text and is doubled. */
|
|
8
|
+
const POINTER_TAGS = new Set(["FAMC", "FAMS", "HUSB", "WIFE", "CHIL", "SOUR", "REPO", "ASSO", "SUBM", "SUBN", "OBJE", "NOTE", "ALIA", "ANCI", "DESI"]);
|
|
9
|
+
/** GEDCOM 5.5.1 writes a literal "@" as "@@" (so "@I1@ BIRT" in a note is not a pointer). */
|
|
10
|
+
export function escapeAt(tag, value) {
|
|
11
|
+
if (POINTER_TAGS.has(tag) && /^@[^@\s]+@$/.test(value))
|
|
12
|
+
return value;
|
|
13
|
+
return value.replace(/@/g, "@@");
|
|
14
|
+
}
|
|
15
|
+
const utf8 = (s) => Buffer.byteLength(s, "utf8");
|
|
16
|
+
/** Longest prefix of `s` (in whole code points) that fits in `bytes`. */
|
|
17
|
+
function fit(s, bytes) {
|
|
18
|
+
let used = 0;
|
|
19
|
+
let i = 0;
|
|
20
|
+
for (const ch of s) {
|
|
21
|
+
const b = utf8(ch);
|
|
22
|
+
if (used + b > bytes)
|
|
23
|
+
break;
|
|
24
|
+
used += b;
|
|
25
|
+
i += ch.length;
|
|
26
|
+
}
|
|
27
|
+
return i;
|
|
28
|
+
}
|
|
29
|
+
/** Split one paragraph into chunks of at most `bytes`, never cutting next to a space. */
|
|
30
|
+
export function splitParagraph(text, bytes) {
|
|
31
|
+
const out = [];
|
|
32
|
+
let rest = text;
|
|
33
|
+
while (utf8(rest) > bytes) {
|
|
34
|
+
let cut = fit(rest, bytes);
|
|
35
|
+
// never next to a space, never between the two halves of an escaped "@@", never
|
|
36
|
+
// between a letter and its accent (a combining mark with no precomposed form)
|
|
37
|
+
while (cut > 1 && (rest[cut - 1] === " " || rest[cut] === " " || (rest[cut - 1] === "@" && rest[cut] === "@") || /^\p{M}/u.test(rest.slice(cut))))
|
|
38
|
+
cut--;
|
|
39
|
+
if (cut <= 1)
|
|
40
|
+
cut = fit(rest, bytes); // a run of spaces: nothing better possible
|
|
41
|
+
out.push(rest.slice(0, cut));
|
|
42
|
+
rest = rest.slice(cut);
|
|
43
|
+
}
|
|
44
|
+
out.push(rest);
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
export class GedWriter {
|
|
48
|
+
lines = [];
|
|
49
|
+
// Text is written composed (NFC): "č" as one letter, as every reader compares it —
|
|
50
|
+
// whatever form it came in (macOS file names, copied text are often decomposed).
|
|
51
|
+
line(level, tag, value) {
|
|
52
|
+
const v = value === undefined || value === null ? "" : escapeAt(tag, String(value).normalize("NFC").replace(/[\r\n]+/g, " ").trim());
|
|
53
|
+
this.lines.push(v ? `${level} ${tag} ${v}` : `${level} ${tag}`);
|
|
54
|
+
}
|
|
55
|
+
/** A line whose value is already escaped (continuations of a text). */
|
|
56
|
+
raw(level, tag, v) {
|
|
57
|
+
this.lines.push(v ? `${level} ${tag} ${v}` : `${level} ${tag}`);
|
|
58
|
+
}
|
|
59
|
+
/** A record header: "0 @I1@ INDI". */
|
|
60
|
+
record(xref, tag, value) {
|
|
61
|
+
this.lines.push(value ? `0 ${xref} ${tag} ${value}` : `0 ${xref} ${tag}`);
|
|
62
|
+
}
|
|
63
|
+
/** A tag with free text of any length: CONT for line breaks, CONC for long lines. */
|
|
64
|
+
text(level, tag, text) {
|
|
65
|
+
const paragraphs = text
|
|
66
|
+
.normalize("NFC")
|
|
67
|
+
.replace(/\r\n?/g, "\n")
|
|
68
|
+
.split("\n")
|
|
69
|
+
.map((p) => p.replace(/\s+/g, " ").trim().replace(/@/g, "@@"));
|
|
70
|
+
paragraphs.forEach((para, i) => {
|
|
71
|
+
const lineTag = i === 0 ? tag : "CONT";
|
|
72
|
+
const lvl = i === 0 ? level : level + 1;
|
|
73
|
+
const first = splitParagraph(para, MAX_LINE_BYTES - utf8(`${lvl} ${lineTag} `))[0] ?? "";
|
|
74
|
+
this.raw(lvl, lineTag, first);
|
|
75
|
+
const rest = para.slice(first.length);
|
|
76
|
+
for (const chunk of splitParagraph(rest, MAX_LINE_BYTES - utf8(`${level + 1} CONC `)))
|
|
77
|
+
if (chunk)
|
|
78
|
+
this.raw(level + 1, "CONC", chunk);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
toString() {
|
|
82
|
+
return this.lines.join("\n") + "\n";
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/** Reassemble a text value from a tag line and its CONC/CONT children (for tests and import). */
|
|
86
|
+
export function joinText(value, continuation) {
|
|
87
|
+
let out = value;
|
|
88
|
+
for (const c of continuation)
|
|
89
|
+
out += c.tag === "CONT" ? `\n${c.value}` : c.value;
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// GEDCOM reader: lines → a node tree, CONC/CONT joined. Tolerant: this reads
|
|
2
|
+
// files written by any program (and by people), not just our own.
|
|
3
|
+
const LINE = /^\s*(\d{1,2})\s+(?:(@[^@\s]+@)\s+)?([A-Za-z0-9_]+)(?:\s(.*))?$/;
|
|
4
|
+
export function parseGedcomText(text) {
|
|
5
|
+
const problems = [];
|
|
6
|
+
const records = [];
|
|
7
|
+
const stack = [];
|
|
8
|
+
const rows = text.replace(/^/, "").split(/\r\n|\r|\n/);
|
|
9
|
+
rows.forEach((raw, i) => {
|
|
10
|
+
if (!raw.trim())
|
|
11
|
+
return;
|
|
12
|
+
const m = LINE.exec(raw);
|
|
13
|
+
if (!m) {
|
|
14
|
+
problems.push(`line ${i + 1}: not a GEDCOM line`);
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
// "@@" is a literal "@" (5.5.1); a lone pointer value stays as it is.
|
|
18
|
+
const rawValue = m[4] ?? "";
|
|
19
|
+
const value = /^@[^@\s]+@$/.test(rawValue) ? rawValue : rawValue.replace(/@@/g, "@");
|
|
20
|
+
const node = { level: Number(m[1]), tag: m[3].toUpperCase(), value, line: i + 1, children: [] };
|
|
21
|
+
if (m[2])
|
|
22
|
+
node.xref = m[2];
|
|
23
|
+
if (node.tag === "CONC" || node.tag === "CONT") {
|
|
24
|
+
const parent = stack[node.level - 1];
|
|
25
|
+
if (parent)
|
|
26
|
+
parent.value += (node.tag === "CONT" ? "\n" : "") + node.value;
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
stack.length = node.level;
|
|
30
|
+
if (node.level === 0)
|
|
31
|
+
records.push(node);
|
|
32
|
+
else {
|
|
33
|
+
const parent = stack[node.level - 1];
|
|
34
|
+
if (!parent) {
|
|
35
|
+
problems.push(`line ${i + 1}: level ${node.level} without a parent`);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
parent.children.push(node);
|
|
39
|
+
}
|
|
40
|
+
stack[node.level] = node;
|
|
41
|
+
});
|
|
42
|
+
return { records, problems };
|
|
43
|
+
}
|
|
44
|
+
export function child(n, tag) {
|
|
45
|
+
return n.children.find((c) => c.tag === tag);
|
|
46
|
+
}
|
|
47
|
+
export function children(n, tag) {
|
|
48
|
+
return n.children.filter((c) => c.tag === tag);
|
|
49
|
+
}
|
|
50
|
+
export function val(n, tag) {
|
|
51
|
+
const c = n ? child(n, tag) : undefined;
|
|
52
|
+
return c?.value.trim() || undefined;
|
|
53
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// GEDCOM 5.5.1 validator, independent of the exporter: it reads the text as
|
|
2
|
+
// any importer would. Runs after every export and on any file:
|
|
3
|
+
// `strom gedcom validate <file>`.
|
|
4
|
+
import { normalizeDate } from "../core/gdate.js";
|
|
5
|
+
import { isGedcomAge } from "../core/age.js";
|
|
6
|
+
import { MAX_LINE_BYTES } from "./lines.js";
|
|
7
|
+
const LINE = /^(\d{1,2}) (?:(@[^@ ]+@) )?([A-Za-z0-9_]+)(?: (.*))?$/;
|
|
8
|
+
const EVENT_DETAIL = ["TYPE", "DATE", "PLAC", "AGE", "SOUR", "NOTE", "ASSO", "_WITN", "CAUS", "ADDR", "AGNC", "RELI", "OBJE", "HUSB", "WIFE"];
|
|
9
|
+
const INDI_EVENTS = [
|
|
10
|
+
"BIRT", "DEAT", "BAPM", "CHR", "CHRA", "CONF", "FCOM", "BARM", "BASM", "ORDN", "EDUC", "GRAD", "OCCU", "RESI", "EMIG", "IMMI",
|
|
11
|
+
"NATU", "RELI", "TITL", "NATI", "ADOP", "WILL", "PROB", "BURI", "CREM", "CENS", "EVEN", "RETI", "BLES", "CAST", "DSCR", "IDNO",
|
|
12
|
+
"NCHI", "NMR", "PROP", "SSN", "FACT",
|
|
13
|
+
];
|
|
14
|
+
const FAM_EVENTS = ["MARR", "DIV", "MARB", "MARC", "MARL", "MARS", "ANUL", "DIVF", "ENGA", "CENS", "EVEN", "NCHI"];
|
|
15
|
+
/** Allowed child tags by parent context ("INDI", "INDI.BIRT", …). CONC/CONT are allowed under anything with text. */
|
|
16
|
+
const CHILDREN = {
|
|
17
|
+
HEAD: ["SOUR", "DEST", "DATE", "SUBM", "SUBN", "FILE", "COPR", "GEDC", "CHAR", "LANG", "PLAC", "NOTE"],
|
|
18
|
+
"HEAD.SOUR": ["VERS", "NAME", "CORP", "DATA"],
|
|
19
|
+
"HEAD.GEDC": ["VERS", "FORM"],
|
|
20
|
+
"HEAD.DATE": ["TIME"],
|
|
21
|
+
INDI: ["NAME", "SEX", "FAMC", "FAMS", "NOTE", "SOUR", "REFN", "ASSO", "ALIA", "OBJE", "RESN", "RIN", "CHAN", "_STORY", ...INDI_EVENTS],
|
|
22
|
+
"INDI.NAME": ["TYPE", "GIVN", "SURN", "NPFX", "NSFX", "NICK", "SPFX", "SOUR", "NOTE"],
|
|
23
|
+
"INDI.FAMC": ["PEDI", "NOTE"],
|
|
24
|
+
"INDI.FAMS": ["NOTE"],
|
|
25
|
+
"FAM.CHIL": ["_FREL", "_MREL"],
|
|
26
|
+
"INDI.ASSO": ["RELA", "SOUR", "NOTE"],
|
|
27
|
+
FAM: ["HUSB", "WIFE", "CHIL", "NOTE", "SOUR", "REFN", "OBJE", "RIN", "CHAN", "_STORY", ...FAM_EVENTS],
|
|
28
|
+
SOUR: ["TITL", "AUTH", "ABBR", "PUBL", "TEXT", "REPO", "NOTE", "REFN", "DATA", "OBJE", "RIN", "CHAN"],
|
|
29
|
+
"SOUR.REPO": ["CALN", "NOTE"],
|
|
30
|
+
REPO: ["NAME", "ADDR", "NOTE", "REFN", "RIN", "CHAN"],
|
|
31
|
+
SUBM: ["NAME", "ADDR", "LANG", "NOTE", "RIN", "CHAN"],
|
|
32
|
+
NOTE: ["SOUR", "REFN", "RIN", "CHAN"],
|
|
33
|
+
EVENT: EVENT_DETAIL,
|
|
34
|
+
CITATION: ["PAGE", "QUAY", "NOTE", "DATA", "EVEN"],
|
|
35
|
+
"CITATION.DATA": ["DATE", "TEXT"],
|
|
36
|
+
PLAC: ["MAP", "FORM", "NOTE"],
|
|
37
|
+
MAP: ["LATI", "LONG"],
|
|
38
|
+
"EVENT.ASSO": ["RELA", "NOTE"],
|
|
39
|
+
"EVENT.HUSB": ["AGE"],
|
|
40
|
+
"EVENT.WIFE": ["AGE"],
|
|
41
|
+
_STORY: ["TYPE", "TITL", "STAT", "TEXT", "DATA", "NOTE"],
|
|
42
|
+
_WITN: ["RELA", "NOTE"],
|
|
43
|
+
ADDR: ["CONT", "ADR1", "ADR2", "ADR3", "CITY", "STAE", "POST", "CTRY"],
|
|
44
|
+
};
|
|
45
|
+
const TEXT_TAGS = new Set(["NOTE", "TEXT", "TITL", "PAGE", "AUTH", "PUBL", "_STORY", "DATA", "COPR", "ADDR", "CAUS"]);
|
|
46
|
+
const EXTENSIONS = new Set(["_STORY", "_WITN", "_FREL", "_MREL"]);
|
|
47
|
+
/** Context of the children of the last tag in `anc` (the ancestors of a line). */
|
|
48
|
+
function contextOf(anc) {
|
|
49
|
+
const record = anc[0];
|
|
50
|
+
const parent = anc[anc.length - 1];
|
|
51
|
+
const depth = anc.length; // level of the child line
|
|
52
|
+
if (depth === 1)
|
|
53
|
+
return record;
|
|
54
|
+
if (parent === "SOUR" && record !== "HEAD" && !(record === "SOUR" && depth === 1))
|
|
55
|
+
return "CITATION";
|
|
56
|
+
if (parent === "PLAC")
|
|
57
|
+
return "PLAC";
|
|
58
|
+
if (parent === "MAP")
|
|
59
|
+
return "MAP";
|
|
60
|
+
if (parent === "_STORY")
|
|
61
|
+
return "_STORY";
|
|
62
|
+
if (parent === "_WITN")
|
|
63
|
+
return "_WITN";
|
|
64
|
+
if (parent === "ADDR")
|
|
65
|
+
return "ADDR";
|
|
66
|
+
if (parent === "DATA" && anc[depth - 2] === "SOUR")
|
|
67
|
+
return "CITATION.DATA";
|
|
68
|
+
if (depth === 2) {
|
|
69
|
+
if (record === "INDI" && INDI_EVENTS.includes(parent))
|
|
70
|
+
return "EVENT";
|
|
71
|
+
if (record === "FAM" && FAM_EVENTS.includes(parent))
|
|
72
|
+
return "EVENT";
|
|
73
|
+
return `${record}.${parent}`;
|
|
74
|
+
}
|
|
75
|
+
if (depth === 3 && parent === "ASSO")
|
|
76
|
+
return "EVENT.ASSO";
|
|
77
|
+
if (depth === 3 && (parent === "HUSB" || parent === "WIFE") && record === "FAM")
|
|
78
|
+
return `EVENT.${parent}`;
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
export function validateGedcom(text, opts = {}) {
|
|
82
|
+
const out = [];
|
|
83
|
+
const add = (level, line, message) => out.push({ level, line, message });
|
|
84
|
+
if (text.charCodeAt(0) === 0xfeff)
|
|
85
|
+
add("warn", 1, "file starts with a byte-order mark");
|
|
86
|
+
const rows = text.replace(/\r\n?/g, "\n").replace(/\n$/, "").split("\n");
|
|
87
|
+
const defined = new Map();
|
|
88
|
+
const pointers = [];
|
|
89
|
+
const stack = [];
|
|
90
|
+
let prevLevel = -1;
|
|
91
|
+
let charDeclared = false;
|
|
92
|
+
const headTags = new Set();
|
|
93
|
+
rows.forEach((raw, i) => {
|
|
94
|
+
const n = i + 1;
|
|
95
|
+
if (Buffer.byteLength(raw, "utf8") > MAX_LINE_BYTES)
|
|
96
|
+
add("error", n, `line is ${Buffer.byteLength(raw, "utf8")} bytes (max ${MAX_LINE_BYTES})`);
|
|
97
|
+
const m = LINE.exec(raw);
|
|
98
|
+
if (!m)
|
|
99
|
+
return void add("error", n, `not a GEDCOM line: ${raw.slice(0, 60)}`);
|
|
100
|
+
const level = Number(m[1]);
|
|
101
|
+
const xref = m[2];
|
|
102
|
+
const tag = m[3];
|
|
103
|
+
const value = m[4];
|
|
104
|
+
if (level > prevLevel + 1)
|
|
105
|
+
add("error", n, `level jumps from ${prevLevel} to ${level}`);
|
|
106
|
+
prevLevel = level;
|
|
107
|
+
stack.length = level;
|
|
108
|
+
if (level === 0) {
|
|
109
|
+
if (xref) {
|
|
110
|
+
if (defined.has(xref))
|
|
111
|
+
add("error", n, `${xref} is defined twice`);
|
|
112
|
+
defined.set(xref, tag);
|
|
113
|
+
}
|
|
114
|
+
if (!["HEAD", "TRLR", "INDI", "FAM", "SOUR", "REPO", "NOTE", "SUBM", "SUBN", "OBJE"].includes(tag) && !tag.startsWith("_"))
|
|
115
|
+
add("error", n, `unknown record type ${tag}`);
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
const ctx = contextOf(stack.slice(0, level));
|
|
119
|
+
const allowed = ctx ? CHILDREN[ctx] : undefined;
|
|
120
|
+
const parentTag = stack[level - 1];
|
|
121
|
+
if (tag === "CONC" || tag === "CONT") {
|
|
122
|
+
if (!TEXT_TAGS.has(parentTag) && !["CONC", "CONT"].includes(parentTag))
|
|
123
|
+
add("warn", n, `${tag} under ${parentTag}`);
|
|
124
|
+
}
|
|
125
|
+
else if (allowed && !allowed.includes(tag))
|
|
126
|
+
add(tag.startsWith("_") ? "warn" : "error", n, `${tag} is not expected under ${ctx}`);
|
|
127
|
+
}
|
|
128
|
+
stack[level] = tag;
|
|
129
|
+
if (tag.startsWith("_") && (opts.strict || !EXTENSIONS.has(tag)))
|
|
130
|
+
add(opts.strict ? "error" : "warn", n, `non-standard tag ${tag}`);
|
|
131
|
+
if (value !== undefined && value !== value.trim() && tag !== "CONC")
|
|
132
|
+
add("warn", n, `value of ${tag} starts or ends with a space`);
|
|
133
|
+
if (tag === "CONC" && value !== undefined && (value.startsWith(" ") || value.endsWith(" ")))
|
|
134
|
+
add("warn", n, "CONC value starts or ends with a space (readers may trim it)");
|
|
135
|
+
if (value && /^@[^@ ]+@$/.test(value) && tag !== "CONC" && tag !== "CONT" && tag !== "NOTE")
|
|
136
|
+
pointers.push({ xref: value, line: n, tag });
|
|
137
|
+
else if (value && value.replace(/@@/g, "").includes("@") && !/^@#D[A-Z ]+@/.test(value))
|
|
138
|
+
add("error", n, `${tag}: a literal "@" must be written "@@" (otherwise readers take it for a pointer)`);
|
|
139
|
+
if (tag === "AGE" && value !== undefined && !isGedcomAge(value))
|
|
140
|
+
add("error", n, `AGE "${value}" is not a GEDCOM age (27y, 27y 3m, <1y, INFANT, STILLBORN, CHILD)`);
|
|
141
|
+
if (tag === "AGE" && stack[0] === "FAM" && level === 2)
|
|
142
|
+
add("error", n, "AGE of a family event belongs under HUSB or WIFE");
|
|
143
|
+
if (opts.strict && tag === "ASSO" && level > 1)
|
|
144
|
+
add("error", n, "ASSO under an event is GEDCOM 7; in 5.5.1 it belongs to the individual (1 ASSO)");
|
|
145
|
+
if (level === 1 && stack[0] === "HEAD")
|
|
146
|
+
headTags.add(tag);
|
|
147
|
+
if (tag === "CHAR" && level === 1) {
|
|
148
|
+
charDeclared = true;
|
|
149
|
+
if (value !== "UTF-8")
|
|
150
|
+
add("error", n, `CHAR ${value} — Strom Research writes UTF-8`);
|
|
151
|
+
}
|
|
152
|
+
if (tag === "DATE" && value && stack[level - 1] !== "HEAD" && normalizeDate(value) !== value)
|
|
153
|
+
add("error", n, `invalid date "${value}"`);
|
|
154
|
+
if (tag === "SEX" && value && !["M", "F", "U"].includes(value))
|
|
155
|
+
add("error", n, `SEX must be M, F or U, not "${value}"`);
|
|
156
|
+
if (tag === "QUAY" && value && !/^[0-3]$/.test(value))
|
|
157
|
+
add("error", n, `QUAY must be 0–3, not "${value}"`);
|
|
158
|
+
if (tag === "PEDI" && value && !["adopted", "birth", "foster", "sealing"].includes(value))
|
|
159
|
+
add(opts.strict ? "error" : "warn", n, `PEDI "${value}" is not GEDCOM 5.5.1 (adopted, birth, foster, sealing)`);
|
|
160
|
+
if (tag === "NAME" && level === 1 && stack[0] === "INDI" && value && (value.split("/").length - 1) % 2 !== 0)
|
|
161
|
+
add("error", n, `NAME "${value}": unbalanced surname slashes`);
|
|
162
|
+
});
|
|
163
|
+
if (rows[0] !== "0 HEAD")
|
|
164
|
+
add("error", 1, "file must start with 0 HEAD");
|
|
165
|
+
if (rows[rows.length - 1] !== "0 TRLR")
|
|
166
|
+
add("error", rows.length, "file must end with 0 TRLR");
|
|
167
|
+
if (!charDeclared)
|
|
168
|
+
add("error", 1, "HEAD has no 1 CHAR UTF-8");
|
|
169
|
+
for (const t of ["SOUR", "GEDC", "SUBM"])
|
|
170
|
+
if (!headTags.has(t))
|
|
171
|
+
add(opts.strict ? "error" : "warn", 1, `HEAD has no ${t} (required by GEDCOM 5.5.1)`);
|
|
172
|
+
if (headTags.has("SUBM") && ![...defined.values()].includes("SUBM"))
|
|
173
|
+
add(opts.strict ? "error" : "warn", 1, "the SUBM record named in HEAD is missing");
|
|
174
|
+
for (const p of pointers) {
|
|
175
|
+
const target = defined.get(p.xref);
|
|
176
|
+
if (!target)
|
|
177
|
+
add("error", p.line, `${p.tag} points to ${p.xref}, which is not defined`);
|
|
178
|
+
}
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
export function hasGedErrors(findings) {
|
|
182
|
+
return findings.some((f) => f.level === "error");
|
|
183
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// Raster images in memory and what is done with scans: crop, scale, contrast,
|
|
2
|
+
// rotation, a grid to point at places. Pure TypeScript, no dependencies —
|
|
3
|
+
// the codecs are in jpeg.ts and png.ts.
|
|
4
|
+
export function blank(width, height, channels, value = 255) {
|
|
5
|
+
return { width, height, channels, data: new Uint8Array(width * height * channels).fill(value) };
|
|
6
|
+
}
|
|
7
|
+
/** Cut out a rectangle (clamped to the image). */
|
|
8
|
+
export function crop(img, x, y, w, h) {
|
|
9
|
+
const x0 = Math.max(0, Math.min(img.width - 1, Math.round(x)));
|
|
10
|
+
const y0 = Math.max(0, Math.min(img.height - 1, Math.round(y)));
|
|
11
|
+
const cw = Math.max(1, Math.min(img.width - x0, Math.round(w)));
|
|
12
|
+
const ch = Math.max(1, Math.min(img.height - y0, Math.round(h)));
|
|
13
|
+
const c = img.channels;
|
|
14
|
+
const out = new Uint8Array(cw * ch * c);
|
|
15
|
+
for (let row = 0; row < ch; row++) {
|
|
16
|
+
const from = ((y0 + row) * img.width + x0) * c;
|
|
17
|
+
out.set(img.data.subarray(from, from + cw * c), row * cw * c);
|
|
18
|
+
}
|
|
19
|
+
return { width: cw, height: ch, channels: c, data: out };
|
|
20
|
+
}
|
|
21
|
+
/** Resize to w × h: area averaging when shrinking (no aliasing on fine script), bilinear when enlarging. */
|
|
22
|
+
export function resize(img, w, h) {
|
|
23
|
+
w = Math.max(1, Math.round(w));
|
|
24
|
+
h = Math.max(1, Math.round(h));
|
|
25
|
+
if (w === img.width && h === img.height)
|
|
26
|
+
return img;
|
|
27
|
+
const c = img.channels;
|
|
28
|
+
const out = new Uint8Array(w * h * c);
|
|
29
|
+
const sx = img.width / w;
|
|
30
|
+
const sy = img.height / h;
|
|
31
|
+
if (sx >= 1 && sy >= 1) {
|
|
32
|
+
// Each output pixel is the mean of the source area it covers (with fractional edges).
|
|
33
|
+
const acc = new Float64Array(c);
|
|
34
|
+
for (let oy = 0; oy < h; oy++) {
|
|
35
|
+
const fy0 = oy * sy;
|
|
36
|
+
const fy1 = fy0 + sy;
|
|
37
|
+
for (let ox = 0; ox < w; ox++) {
|
|
38
|
+
const fx0 = ox * sx;
|
|
39
|
+
const fx1 = fx0 + sx;
|
|
40
|
+
acc.fill(0);
|
|
41
|
+
let total = 0;
|
|
42
|
+
for (let yy = Math.floor(fy0); yy < Math.min(img.height, Math.ceil(fy1)); yy++) {
|
|
43
|
+
const wy = Math.min(fy1, yy + 1) - Math.max(fy0, yy);
|
|
44
|
+
for (let xx = Math.floor(fx0); xx < Math.min(img.width, Math.ceil(fx1)); xx++) {
|
|
45
|
+
const wgt = wy * (Math.min(fx1, xx + 1) - Math.max(fx0, xx));
|
|
46
|
+
const p = (yy * img.width + xx) * c;
|
|
47
|
+
for (let k = 0; k < c; k++)
|
|
48
|
+
acc[k] += img.data[p + k] * wgt;
|
|
49
|
+
total += wgt;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const o = (oy * w + ox) * c;
|
|
53
|
+
for (let k = 0; k < c; k++)
|
|
54
|
+
out[o + k] = Math.round(acc[k] / total);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return { width: w, height: h, channels: c, data: out };
|
|
58
|
+
}
|
|
59
|
+
for (let oy = 0; oy < h; oy++) {
|
|
60
|
+
const fy = Math.max(0, Math.min(img.height - 1, (oy + 0.5) * sy - 0.5));
|
|
61
|
+
const y0 = Math.floor(fy);
|
|
62
|
+
const y1 = Math.min(img.height - 1, y0 + 1);
|
|
63
|
+
const ty = fy - y0;
|
|
64
|
+
for (let ox = 0; ox < w; ox++) {
|
|
65
|
+
const fx = Math.max(0, Math.min(img.width - 1, (ox + 0.5) * sx - 0.5));
|
|
66
|
+
const x0 = Math.floor(fx);
|
|
67
|
+
const x1 = Math.min(img.width - 1, x0 + 1);
|
|
68
|
+
const tx = fx - x0;
|
|
69
|
+
const o = (oy * w + ox) * c;
|
|
70
|
+
for (let k = 0; k < c; k++) {
|
|
71
|
+
const a = img.data[(y0 * img.width + x0) * c + k];
|
|
72
|
+
const b = img.data[(y0 * img.width + x1) * c + k];
|
|
73
|
+
const d = img.data[(y1 * img.width + x0) * c + k];
|
|
74
|
+
const e = img.data[(y1 * img.width + x1) * c + k];
|
|
75
|
+
out[o + k] = Math.round((a * (1 - tx) + b * tx) * (1 - ty) + (d * (1 - tx) + e * tx) * ty);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return { width: w, height: h, channels: c, data: out };
|
|
80
|
+
}
|
|
81
|
+
export function toRgb(img) {
|
|
82
|
+
if (img.channels === 3)
|
|
83
|
+
return img;
|
|
84
|
+
const out = new Uint8Array(img.width * img.height * 3);
|
|
85
|
+
for (let i = 0, p = 0; i < img.data.length; i++, p += 3)
|
|
86
|
+
out[p] = out[p + 1] = out[p + 2] = img.data[i];
|
|
87
|
+
return { width: img.width, height: img.height, channels: 3, data: out };
|
|
88
|
+
}
|
|
89
|
+
/** Put a picture into another at x, y (its top left corner), clipped at the edges; same channels. */
|
|
90
|
+
export function paste(into, img, x, y) {
|
|
91
|
+
const c = into.channels;
|
|
92
|
+
const x0 = Math.max(0, x);
|
|
93
|
+
const x1 = Math.min(into.width, x + img.width);
|
|
94
|
+
if (x1 <= x0)
|
|
95
|
+
return;
|
|
96
|
+
for (let row = Math.max(0, y); row < Math.min(into.height, y + img.height); row++) {
|
|
97
|
+
const from = ((row - y) * img.width + (x0 - x)) * c;
|
|
98
|
+
into.data.set(img.data.subarray(from, from + (x1 - x0) * c), (row * into.width + x0) * c);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
export function toGrey(img) {
|
|
102
|
+
if (img.channels === 1)
|
|
103
|
+
return img;
|
|
104
|
+
const out = new Uint8Array(img.width * img.height);
|
|
105
|
+
for (let i = 0, p = 0; i < out.length; i++, p += 3)
|
|
106
|
+
out[i] = Math.round(0.299 * img.data[p] + 0.587 * img.data[p + 1] + 0.114 * img.data[p + 2]);
|
|
107
|
+
return { width: img.width, height: img.height, channels: 1, data: out };
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Stretch the contrast between two percentiles of brightness. Done on the
|
|
111
|
+
* part being read, never on the whole scan (a dark margin would flatten the
|
|
112
|
+
* ink of the column — a lesson from reading faded registers).
|
|
113
|
+
*/
|
|
114
|
+
export function stretch(img, lowPct = 1, highPct = 99) {
|
|
115
|
+
const c = img.channels;
|
|
116
|
+
const hist = new Uint32Array(256);
|
|
117
|
+
const n = img.width * img.height;
|
|
118
|
+
for (let i = 0; i < n; i++) {
|
|
119
|
+
const p = i * c;
|
|
120
|
+
const lum = c === 1 ? img.data[p] : Math.round(0.299 * img.data[p] + 0.587 * img.data[p + 1] + 0.114 * img.data[p + 2]);
|
|
121
|
+
hist[lum]++;
|
|
122
|
+
}
|
|
123
|
+
const at = (pct) => {
|
|
124
|
+
let acc = 0;
|
|
125
|
+
// at least one pixel: 0 % is the darkest value present, 100 % the lightest
|
|
126
|
+
const target = Math.max(1, Math.ceil((pct / 100) * n));
|
|
127
|
+
for (let v = 0; v < 256; v++) {
|
|
128
|
+
acc += hist[v];
|
|
129
|
+
if (acc >= target)
|
|
130
|
+
return v;
|
|
131
|
+
}
|
|
132
|
+
return 255;
|
|
133
|
+
};
|
|
134
|
+
const lo = at(lowPct);
|
|
135
|
+
const hi = Math.max(lo + 1, at(highPct));
|
|
136
|
+
const map = new Uint8Array(256);
|
|
137
|
+
for (let v = 0; v < 256; v++)
|
|
138
|
+
map[v] = Math.max(0, Math.min(255, Math.round(((v - lo) * 255) / (hi - lo))));
|
|
139
|
+
const out = new Uint8Array(img.data.length);
|
|
140
|
+
for (let i = 0; i < out.length; i++)
|
|
141
|
+
out[i] = map[img.data[i]];
|
|
142
|
+
return { ...img, data: out };
|
|
143
|
+
}
|
|
144
|
+
/** Rotate by 90, 180 or 270 degrees clockwise. */
|
|
145
|
+
export function rotate(img, degrees) {
|
|
146
|
+
const { width: w, height: h, channels: c } = img;
|
|
147
|
+
const nw = degrees === 180 ? w : h;
|
|
148
|
+
const nh = degrees === 180 ? h : w;
|
|
149
|
+
const out = new Uint8Array(img.data.length);
|
|
150
|
+
for (let y = 0; y < h; y++)
|
|
151
|
+
for (let x = 0; x < w; x++) {
|
|
152
|
+
const [nx, ny] = degrees === 90 ? [h - 1 - y, x] : degrees === 180 ? [w - 1 - x, h - 1 - y] : [y, w - 1 - x];
|
|
153
|
+
const s = (y * w + x) * c;
|
|
154
|
+
const d = (ny * nw + nx) * c;
|
|
155
|
+
for (let k = 0; k < c; k++)
|
|
156
|
+
out[d + k] = img.data[s + k];
|
|
157
|
+
}
|
|
158
|
+
return { width: nw, height: nh, channels: c, data: out };
|
|
159
|
+
}
|
|
160
|
+
/** 3×5 pixel digits for grid labels. */
|
|
161
|
+
const DIGITS = ["111101101101111", "010110010010111", "111001111100111", "111001111001111", "101101111001001", "111100111001111", "111100111101111", "111001001001001", "111101111101111", "111101111001111"];
|
|
162
|
+
function label(img, text, x, y, scale) {
|
|
163
|
+
const c = img.channels;
|
|
164
|
+
const put = (px, py, v) => {
|
|
165
|
+
if (px < 0 || py < 0 || px >= img.width || py >= img.height)
|
|
166
|
+
return;
|
|
167
|
+
const p = (py * img.width + px) * c;
|
|
168
|
+
for (let k = 0; k < c; k++)
|
|
169
|
+
img.data[p + k] = v[k] ?? v[0];
|
|
170
|
+
};
|
|
171
|
+
const fg = c === 3 ? [200, 0, 0] : [0];
|
|
172
|
+
const bg = [255, 255, 255];
|
|
173
|
+
const cw = 4 * scale;
|
|
174
|
+
// white box behind, so the digits stay readable on ink
|
|
175
|
+
for (let py = y - scale; py < y + 6 * scale; py++)
|
|
176
|
+
for (let px = x - scale; px < x + text.length * cw; px++)
|
|
177
|
+
put(px, py, bg);
|
|
178
|
+
[...text].forEach((ch, i) => {
|
|
179
|
+
const bits = DIGITS[Number(ch)];
|
|
180
|
+
if (!bits)
|
|
181
|
+
return;
|
|
182
|
+
for (let r = 0; r < 5; r++)
|
|
183
|
+
for (let col = 0; col < 3; col++)
|
|
184
|
+
if (bits[r * 3 + col] === "1")
|
|
185
|
+
for (let dy = 0; dy < scale; dy++)
|
|
186
|
+
for (let dx = 0; dx < scale; dx++)
|
|
187
|
+
put(x + i * cw + col * scale + dx, y + r * scale + dy, fg);
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* A grid of tenths with labels 0–10 along the edges: the agent reads off where
|
|
192
|
+
* something is ("x 0.3–0.5, y 0.6–0.7") and asks for exactly that crop.
|
|
193
|
+
*/
|
|
194
|
+
export function grid(img, parts = 10) {
|
|
195
|
+
const out = { ...img, data: new Uint8Array(img.data) };
|
|
196
|
+
const c = out.channels;
|
|
197
|
+
const line = c === 3 ? [220, 0, 0] : [0];
|
|
198
|
+
const scale = Math.max(1, Math.round(Math.min(img.width, img.height) / 400));
|
|
199
|
+
const put = (x, y) => {
|
|
200
|
+
const p = (y * out.width + x) * c;
|
|
201
|
+
for (let k = 0; k < c; k++)
|
|
202
|
+
out.data[p + k] = line[k] ?? line[0];
|
|
203
|
+
};
|
|
204
|
+
for (let i = 1; i < parts; i++) {
|
|
205
|
+
const x = Math.round((i * out.width) / parts);
|
|
206
|
+
const y = Math.round((i * out.height) / parts);
|
|
207
|
+
for (let yy = 0; yy < out.height; yy += i % 5 === 0 ? 1 : 2)
|
|
208
|
+
for (let t = 0; t < (i % 5 === 0 ? scale + 1 : scale); t++)
|
|
209
|
+
if (x + t < out.width)
|
|
210
|
+
put(x + t, yy);
|
|
211
|
+
for (let xx = 0; xx < out.width; xx += i % 5 === 0 ? 1 : 2)
|
|
212
|
+
for (let t = 0; t < (i % 5 === 0 ? scale + 1 : scale); t++)
|
|
213
|
+
if (y + t < out.height)
|
|
214
|
+
put(xx, y + t);
|
|
215
|
+
}
|
|
216
|
+
for (let i = 0; i <= parts; i++) {
|
|
217
|
+
const x = Math.min(out.width - 8 * scale, Math.round((i * out.width) / parts) + 2 * scale);
|
|
218
|
+
const y = Math.min(out.height - 8 * scale, Math.round((i * out.height) / parts) + 2 * scale);
|
|
219
|
+
label(out, String(i), x, 2 * scale, scale);
|
|
220
|
+
label(out, String(i), 2 * scale, y, scale);
|
|
221
|
+
}
|
|
222
|
+
return out;
|
|
223
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Reading and writing image files by their content, not their name.
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import { decodeJpeg } from "./jpeg-decode.js";
|
|
4
|
+
import { encodeJpeg } from "./jpeg-encode.js";
|
|
5
|
+
import { decodePng, encodePng } from "./png.js";
|
|
6
|
+
export function formatOf(bytes) {
|
|
7
|
+
const b = (i) => bytes[i] ?? -1;
|
|
8
|
+
if (b(0) === 0xff && b(1) === 0xd8)
|
|
9
|
+
return "jpeg";
|
|
10
|
+
if (b(0) === 0x89 && b(1) === 0x50 && b(2) === 0x4e && b(3) === 0x47)
|
|
11
|
+
return "png";
|
|
12
|
+
if ((b(0) === 0x49 && b(1) === 0x49 && b(2) === 0x2a) || (b(0) === 0x4d && b(1) === 0x4d && b(3) === 0x2a))
|
|
13
|
+
return "tiff";
|
|
14
|
+
if (b(0) === 0x47 && b(1) === 0x49 && b(2) === 0x46)
|
|
15
|
+
return "gif";
|
|
16
|
+
if (b(0) === 0x52 && b(1) === 0x49 && b(2) === 0x46 && b(3) === 0x46 && b(8) === 0x57 && b(9) === 0x45)
|
|
17
|
+
return "webp";
|
|
18
|
+
if (b(4) === 0x66 && b(5) === 0x74 && b(6) === 0x79 && b(7) === 0x70 && [0x68, 0x6d].includes(b(8)))
|
|
19
|
+
return "heic";
|
|
20
|
+
if (b(0) === 0x25 && b(1) === 0x50 && b(2) === 0x44 && b(3) === 0x46)
|
|
21
|
+
return "pdf";
|
|
22
|
+
return "unknown";
|
|
23
|
+
}
|
|
24
|
+
export class ImageFormatError extends Error {
|
|
25
|
+
}
|
|
26
|
+
/** Decode a JPEG or PNG; anything else gets a clear message about what to do. */
|
|
27
|
+
export function decodeImage(bytes) {
|
|
28
|
+
const f = formatOf(bytes);
|
|
29
|
+
if (f === "jpeg")
|
|
30
|
+
return decodeJpeg(bytes);
|
|
31
|
+
if (f === "png")
|
|
32
|
+
return decodePng(bytes);
|
|
33
|
+
throw new ImageFormatError(f === "unknown" ? "not an image strom can read (JPEG or PNG)" : `${f.toUpperCase()} cannot be cropped by strom yet — only JPEG and PNG; convert the file (any image viewer can save it as JPEG)`);
|
|
34
|
+
}
|
|
35
|
+
export function encodeImage(img, format, quality = 88) {
|
|
36
|
+
return format === "png" ? encodePng(img) : encodeJpeg(img, quality);
|
|
37
|
+
}
|
|
38
|
+
/** Width and height without decoding the whole image (JPEG SOF / PNG IHDR). */
|
|
39
|
+
export function imageSize(bytes) {
|
|
40
|
+
const f = formatOf(bytes);
|
|
41
|
+
if (f === "png") {
|
|
42
|
+
const v = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
43
|
+
return { width: v.getUint32(16), height: v.getUint32(20) };
|
|
44
|
+
}
|
|
45
|
+
if (f === "jpeg") {
|
|
46
|
+
let p = 2;
|
|
47
|
+
while (p + 9 < bytes.length) {
|
|
48
|
+
if (bytes[p] !== 0xff) {
|
|
49
|
+
p++;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const m = bytes[p + 1];
|
|
53
|
+
if (m >= 0xc0 && m <= 0xcf && m !== 0xc4 && m !== 0xc8 && m !== 0xcc)
|
|
54
|
+
return { height: (bytes[p + 5] << 8) | bytes[p + 6], width: (bytes[p + 7] << 8) | bytes[p + 8] };
|
|
55
|
+
if (m === 0xd8 || m === 0x01 || (m >= 0xd0 && m <= 0xd7)) {
|
|
56
|
+
p += 2;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
p += 2 + ((bytes[p + 2] << 8) | bytes[p + 3]);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Width and height of an image file, read from its header without loading it: a JPEG is walked
|
|
66
|
+
* segment by segment, so metadata of any size before the frame (EXIF, ICC, thumbnails) does not matter.
|
|
67
|
+
*/
|
|
68
|
+
export function imageSizeOfFile(file) {
|
|
69
|
+
let fd;
|
|
70
|
+
try {
|
|
71
|
+
fd = fs.openSync(file, "r");
|
|
72
|
+
const at = (pos, n) => {
|
|
73
|
+
const b = Buffer.alloc(n);
|
|
74
|
+
return b.subarray(0, fs.readSync(fd, b, 0, n, pos));
|
|
75
|
+
};
|
|
76
|
+
const head = at(0, 32);
|
|
77
|
+
const f = formatOf(head);
|
|
78
|
+
if (f === "png")
|
|
79
|
+
return head.length >= 24 ? { width: head.readUInt32BE(16), height: head.readUInt32BE(20) } : undefined;
|
|
80
|
+
if (f !== "jpeg")
|
|
81
|
+
return undefined;
|
|
82
|
+
const size = fs.fstatSync(fd).size;
|
|
83
|
+
let pos = 2;
|
|
84
|
+
for (let i = 0; i < 10_000 && pos + 4 <= size; i++) {
|
|
85
|
+
const m = at(pos, 4);
|
|
86
|
+
if (m[0] !== 0xff)
|
|
87
|
+
return undefined;
|
|
88
|
+
const marker = m[1];
|
|
89
|
+
if (marker === 0xff) {
|
|
90
|
+
pos++; // fill byte
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
|
|
94
|
+
pos += 2;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
|
|
98
|
+
const sof = at(pos + 5, 4);
|
|
99
|
+
return sof.length === 4 ? { height: sof.readUInt16BE(0), width: sof.readUInt16BE(2) } : undefined;
|
|
100
|
+
}
|
|
101
|
+
if (marker === 0xd9 || marker === 0xda)
|
|
102
|
+
return undefined; // the image data came before any frame
|
|
103
|
+
pos += 2 + m.readUInt16BE(2);
|
|
104
|
+
}
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
if (fd !== undefined)
|
|
112
|
+
fs.closeSync(fd);
|
|
113
|
+
}
|
|
114
|
+
}
|