archive-codec 1.1.2 → 1.3.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/README.md +47 -20
- package/dist/cfb/read.cjs +1 -1
- package/dist/cfb/read.js +1 -1
- package/dist/cfb/write.cjs +297 -0
- package/dist/cfb/write.d.cts +11 -0
- package/dist/cfb/write.d.ts +11 -0
- package/dist/cfb/write.js +295 -0
- package/dist/index.cjs +3 -0
- package/dist/index.d.cts +2 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/zip/detect.d.cts +1 -1
- package/dist/zip/detect.d.ts +1 -1
- package/dist/zip/walk.d.cts +1 -1
- package/dist/zip/walk.d.ts +1 -1
- package/package.json +1 -10
package/README.md
CHANGED
|
@@ -2,13 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/ExaDev/documents.js/tree/main/packages/archive-codec) [](https://www.npmjs.com/package/archive-codec) [](https://www.npmjs.com/package/archive-codec) [](https://github.com/ExaDev/documents.js/actions)
|
|
4
4
|
|
|
5
|
-
> ZIP-in-ZIP recursive walking under depth and cumulative decompressed-size guards, and
|
|
5
|
+
> ZIP-in-ZIP recursive walking under depth and cumulative decompressed-size guards, and classic OLE compound-file ([MS-CFB]) reading and writing — zero document-format knowledge, the archive and container utility package for the [documents.js family](../../README.md). Worker-isomorphic: the same code runs under Node and inside a Cloudflare Workers isolate.
|
|
6
6
|
|
|
7
7
|
Created for [documents.js#564](https://github.com/ExaDev/documents.js/issues/564): nothing in the ecosystem recursed into a nested archive. Most concretely, OOXML's embedded-object model — a docx/pptx carrying a genuinely separate ZIP blob at `word/embeddings/oleObject1.xlsx` — had no safe handling anywhere, and no package guarded against recursive-archive inputs at all (`byte-codec`'s 512 MiB per-stream inflate cap does not compose across recursion). A new sibling was chosen over extending `byte-codec` (whose charter is byte/image primitives, zero container-format knowledge) or doing it inline in `documents.js` (which would repeat the duplication `byte-codec`'s own extraction was meant to avoid). Its first family consumer is `ooxml.js`'s OLE embedded-object recovery — [documents.js#733](https://github.com/ExaDev/documents.js/issues/733) (pptx, `p:oleObj`) and [documents.js#734](https://github.com/ExaDev/documents.js/issues/734) (docx, `o:OLEObject`): an OLE payload part's bytes are checked through `isZipArchive` and, when they are a ZIP, decoded as a nested OOXML package behind this package's guarded walk — the bounded inflate that populates `document-schema.js`'s `ContentEmbeddedObject`/`ContentEmbeddedObjectBlock` (the same vocabulary odf.js embeds formula sub-documents through) with a genuinely recovered sub-document.
|
|
8
8
|
|
|
9
9
|
[documents.js#739](https://github.com/ExaDev/documents.js/issues/739) widened the charter from that ZIP-only v1 scope to the classic OLE compound file, recording the decision explicitly rather than by accident (mirroring the #564 reasoning): real-world Word and PowerPoint files frequently store the embeddee as a `.bin` compound file at `word|ppt/embeddings/oleObject1.bin`, and a CFB reader is container knowledge exactly the way ZIP structure is — sectors, FAT chains, and directory entries, never that any stream is a document. The same recovery now unwraps such a payload's `Package` stream ([MS-OLEDS]'s OLE packaging of the real file) through this package and feeds the packaged ZIP to the unchanged nested decode.
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
[documents.js#815](https://github.com/ExaDev/documents.js/issues/815), [#816](https://github.com/ExaDev/documents.js/issues/816), and [#817](https://github.com/ExaDev/documents.js/issues/817) then needed the other direction. `xls-codec`, `doc-codec`, `ppt-codec`, and `wpd-codec` each read a legacy Office binary format out of an [MS-CFB] container, and none of them can write one back, because there was no container to put their streams into: a `.xls` writer producing a `Workbook` stream, or a `.doc` writer producing `WordDocument` and `1Table`, needs a conformant compound file to hold them. That container is structural knowledge exactly as the reader's is, so `writeCompoundFile` is the mirror of `readCompoundFile` here rather than four hand-rolled emitters in four codecs.
|
|
12
|
+
|
|
13
|
+
Scope: **ZIP containers** (read and write over [`fflate`](https://github.com/101arrowz/fflate), recursive walking of ZIP-in-ZIP entries) and **classic OLE compound files** (bounded [MS-CFB] reading and conformant [MS-CFB] writing, plus the OLE Package stream unwrapping). **tar and gzip are explicitly out of scope.**
|
|
12
14
|
|
|
13
15
|
## Getting started
|
|
14
16
|
|
|
@@ -32,33 +34,34 @@ To run a single test file, pass its path to vitest directly, e.g. `pnpm exec vit
|
|
|
32
34
|
Every module is importable by package-relative path as well as through the barrel — `tsdown` builds one dist file per src module (`root: 'src'`, the same layout ooxml.js ships), and `package.json`'s `./*` exports wildcard maps each subpath onto it:
|
|
33
35
|
|
|
34
36
|
```ts
|
|
35
|
-
import { readCompoundFile } from
|
|
36
|
-
import { walkArchive } from
|
|
37
|
+
import { readCompoundFile } from "archive-codec/cfb/read";
|
|
38
|
+
import { walkArchive } from "archive-codec/zip/walk";
|
|
37
39
|
```
|
|
38
40
|
|
|
39
41
|
The smoke suite (`test/smoke.test.mjs`) is the guard on that advertisement: it loads each module below from the built `dist/` in both module systems, so a build config that stops serving an advertised subpath fails the suite — neither publint nor `attw` catches a wildcard whose targets are missing.
|
|
40
42
|
|
|
41
|
-
| Module
|
|
42
|
-
|
|
43
|
-
| `zip/container`
|
|
44
|
-
| `zip/detect`
|
|
45
|
-
| `zip/walk`
|
|
46
|
-
| `cfb/detect`
|
|
47
|
-
| `cfb/read`
|
|
48
|
-
| `cfb/
|
|
43
|
+
| Module | Exports |
|
|
44
|
+
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
45
|
+
| `zip/container` | `zipPackage` (ordered-entries ZIP write with stored-uncompressed support), `unzipPackage`, `ZipEntry` |
|
|
46
|
+
| `zip/detect` | `detectArchiveFormat` (`'zip' \| 'cfb' \| 'unknown'`), `isZipArchive`, `ArchiveFormat` |
|
|
47
|
+
| `zip/walk` | `walkArchive` (recursive ZIP-in-ZIP walking), `ArchiveWalkEntry`, `ArchiveWalkLimitError`, `MAX_WALK_DEPTH`, `MAX_WALK_TOTAL_BYTES`, `WalkArchiveOptions` |
|
|
48
|
+
| `cfb/detect` | `isCompoundFile` (the `D0 CF 11 E0 …` magic-byte check) |
|
|
49
|
+
| `cfb/read` | `readCompoundFile` (bounded [MS-CFB] stream extraction), `CompoundFileStream`, `CompoundFileFormatError`, `MAX_CFB_TOTAL_STREAM_BYTES`, `ReadCompoundFileOptions` |
|
|
50
|
+
| `cfb/write` | `writeCompoundFile` ([MS-CFB] container generation), `CompoundFileWriteError`, `WriteCompoundFileOptions` — takes the `CompoundFileStream` array `cfb/read` returns |
|
|
51
|
+
| `cfb/ole-package` | `readOlePackage` (OLE Package stream unwrapping), `OlePackage`, `OlePackageFormatError` |
|
|
49
52
|
|
|
50
53
|
### Recursive walking
|
|
51
54
|
|
|
52
55
|
```ts
|
|
53
|
-
import { walkArchive } from
|
|
56
|
+
import { walkArchive } from "archive-codec";
|
|
54
57
|
|
|
55
58
|
// Every entry of every nested ZIP, flattened. Throws ArchiveWalkLimitError if
|
|
56
59
|
// the walk exceeds the depth cap or the cumulative decompressed-bytes budget.
|
|
57
60
|
for (const entry of walkArchive(docxBytes)) {
|
|
58
|
-
entry.path;
|
|
61
|
+
entry.path; // e.g. 'xl/workbook.xml', the path within its own archive
|
|
59
62
|
entry.ancestors; // e.g. ['word/embeddings/oleObject1.xlsx'] -- the nested
|
|
60
|
-
|
|
61
|
-
entry.bytes;
|
|
63
|
+
// ZIP entries descended through to reach this one
|
|
64
|
+
entry.bytes; // decompressed content
|
|
62
65
|
}
|
|
63
66
|
```
|
|
64
67
|
|
|
@@ -67,18 +70,20 @@ Both guards throw rather than truncate: an input outside the contract must fail
|
|
|
67
70
|
### Compound files
|
|
68
71
|
|
|
69
72
|
```ts
|
|
70
|
-
import { readCompoundFile, readOlePackage } from
|
|
73
|
+
import { readCompoundFile, readOlePackage } from "archive-codec";
|
|
71
74
|
|
|
72
75
|
// Every stream of a classic OLE compound file, with its storage path.
|
|
73
76
|
// Throws CompoundFileFormatError on any structural nonconformance.
|
|
74
77
|
for (const stream of readCompoundFile(oleBinBytes)) {
|
|
75
|
-
stream.path;
|
|
78
|
+
stream.path; // e.g. 'Package' -- root-level, or 'ObjectStorage/Package'
|
|
76
79
|
stream.bytes; // the stream's content
|
|
77
80
|
}
|
|
78
81
|
|
|
79
82
|
// The OLE packaging a Word/PowerPoint embed wraps the real file in before
|
|
80
83
|
// storing it as the 'Package' stream: label, paths, and the file's bytes.
|
|
81
|
-
const packageStream = readCompoundFile(oleBinBytes).find(
|
|
84
|
+
const packageStream = readCompoundFile(oleBinBytes).find(
|
|
85
|
+
(s) => s.path === "Package",
|
|
86
|
+
);
|
|
82
87
|
if (packageStream !== undefined) {
|
|
83
88
|
readOlePackage(packageStream.bytes).fileBytes; // often a ZIP for a modern embed
|
|
84
89
|
}
|
|
@@ -86,9 +91,31 @@ if (packageStream !== undefined) {
|
|
|
86
91
|
|
|
87
92
|
Reading is bounded the same way walking is: chain cycles and out-of-range sectors fail against bounds derived from the file's own sector count, and one cumulative extracted-bytes budget (`MAX_CFB_TOTAL_STREAM_BYTES`, 512 MiB — the same figure the family grants one decompressed stream) bounds the multiplication a hostile FAT gains by aliasing one sector into many streams. Every structural failure throws rather than truncating — a malformed compound file fails whole, never a partial stream listing that looks complete. Version 3 (512-byte sectors) and version 4 (4096-byte) files both read; the mini-FAT path every stream shorter than the header's cutoff takes is first-class, because a small real-world embed genuinely lands there.
|
|
88
93
|
|
|
94
|
+
Writing is the mirror image, taking the same `CompoundFileStream` array reading returns:
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
import { readCompoundFile, writeCompoundFile } from "archive-codec";
|
|
98
|
+
|
|
99
|
+
const bytes = writeCompoundFile([
|
|
100
|
+
{ path: "WordDocument", bytes: mainStream },
|
|
101
|
+
{ path: "1Table", bytes: tableStream },
|
|
102
|
+
{ path: "SummaryInformation", bytes: summaryStream },
|
|
103
|
+
{ path: "ObjectPool/_1234/Package", bytes: embeddedFile }, // a nested storage
|
|
104
|
+
]);
|
|
105
|
+
|
|
106
|
+
// ... so re-writing what was read is a round trip, not a translation.
|
|
107
|
+
writeCompoundFile(readCompoundFile(bytes));
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Slash-separated paths name the enclosing storages exactly as reading reports them, so nested storages are written as well as read; a request that cannot be expressed as a conformant file throws `CompoundFileWriteError` rather than producing bytes that only look valid — an over-long or illegally named entry (`\`, `:`, and `!` are the characters [MS-CFB] forbids), an empty path segment, two siblings whose names collide under the format's case-insensitive ordering, or a version 3 stream past the 2 GB the format allows one. Both allocation paths are written: a stream at or above the 4096-byte cutoff takes FAT-chained sectors, one below it a run of 64-byte mini sectors in the root entry's own mini stream. Files past the 6.875 MiB that the header's own 109-entry DIFAT array can address spill into chained DIFAT sectors rather than failing, which matters because a real `.doc` or `.xls` reaches that size routinely.
|
|
111
|
+
|
|
112
|
+
Two details are deliberate rather than incidental. The directory's sibling trees are genuine red-black trees — balanced by construction and coloured so that every [MS-CFB] 2.6.4 constraint holds, including the black-height property — because the sibling tree exists to be binary-searched by name, and the degenerate right-sibling chain that a purely structural reader would still accept is not a search tree. And the output depends only on the set of paths, never on the order they were supplied in, since the directory's order is the format's own name ordering: two callers building the same file from differently ordered lists get identical bytes.
|
|
113
|
+
|
|
114
|
+
Correctness is checked against independent parsers, not only against this package's own reader: the written files are accepted by [`olefile`](https://github.com/decalage2/olefile) in its strict `DEFECT_INCORRECT` mode and by 7-Zip's Compound handler, both of which return byte-identical stream content, and a real LibreOffice-authored `.doc` read through `readCompoundFile` and re-emitted through `writeCompoundFile` still opens in LibreOffice Writer.
|
|
115
|
+
|
|
89
116
|
### ZIP container
|
|
90
117
|
|
|
91
|
-
`zipPackage` takes an
|
|
118
|
+
`zipPackage` takes an _ordered_ array of `[path, entry]` tuples, not a `Record`, so the caller controls the exact emission order deterministically (the property formats with a fixed-offset first entry — ODF's `mimetype` — depend on), and any entry can be written stored-uncompressed via `stored: true`. `unzipPackage` is the read side; the returned `Record` makes no ordering promise and collapses duplicate paths.
|
|
92
119
|
|
|
93
120
|
## Conventions
|
|
94
121
|
|
package/dist/cfb/read.cjs
CHANGED
|
@@ -113,7 +113,7 @@ function readCompoundFile(bytes, options = {}) {
|
|
|
113
113
|
});
|
|
114
114
|
}
|
|
115
115
|
const root = entries[0];
|
|
116
|
-
if (root
|
|
116
|
+
if (root?.objectType !== OBJECT_TYPE_ROOT) throw new CompoundFileFormatError("the first directory entry is not the root storage entry (object type 5), as [MS-CFB] 2.6.1 requires");
|
|
117
117
|
const miniStream = chainBytes(root.startSector).subarray(0, root.size);
|
|
118
118
|
const miniSectorCount = Math.floor(miniStream.length / miniSectorSize);
|
|
119
119
|
const miniFatBytes = chainBytes(firstMiniFatSector);
|
package/dist/cfb/read.js
CHANGED
|
@@ -112,7 +112,7 @@ function readCompoundFile(bytes, options = {}) {
|
|
|
112
112
|
});
|
|
113
113
|
}
|
|
114
114
|
const root = entries[0];
|
|
115
|
-
if (root
|
|
115
|
+
if (root?.objectType !== OBJECT_TYPE_ROOT) throw new CompoundFileFormatError("the first directory entry is not the root storage entry (object type 5), as [MS-CFB] 2.6.1 requires");
|
|
116
116
|
const miniStream = chainBytes(root.startSector).subarray(0, root.size);
|
|
117
117
|
const miniSectorCount = Math.floor(miniStream.length / miniSectorSize);
|
|
118
118
|
const miniFatBytes = chainBytes(firstMiniFatSector);
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
//#region src/cfb/write.ts
|
|
3
|
+
const ENDOFCHAIN = 4294967294;
|
|
4
|
+
const FATSECT = 4294967293;
|
|
5
|
+
const DIFSECT = 4294967292;
|
|
6
|
+
const NOSTREAM = 4294967295;
|
|
7
|
+
const FREESECT_FILL_BYTE = 255;
|
|
8
|
+
const HEADER_DIFAT_ENTRIES = 109;
|
|
9
|
+
const HEADER_DIFAT_OFFSET = 76;
|
|
10
|
+
const DIRECTORY_ENTRY_SIZE = 128;
|
|
11
|
+
const MAX_NAME_CODE_UNITS = 31;
|
|
12
|
+
const MINI_SECTOR_SHIFT = 6;
|
|
13
|
+
const MINI_SECTOR_SIZE = 64;
|
|
14
|
+
const MINI_STREAM_CUTOFF = 4096;
|
|
15
|
+
const OBJECT_TYPE_STORAGE = 1;
|
|
16
|
+
const OBJECT_TYPE_STREAM = 2;
|
|
17
|
+
const OBJECT_TYPE_ROOT = 5;
|
|
18
|
+
const COLOUR_RED = 0;
|
|
19
|
+
const COLOUR_BLACK = 1;
|
|
20
|
+
const ROOT_ENTRY_NAME = "Root Entry";
|
|
21
|
+
const MAX_VERSION_3_STREAM_BYTES = 2147483648;
|
|
22
|
+
const ILLEGAL_NAME_CHARACTERS = [
|
|
23
|
+
"\\",
|
|
24
|
+
":",
|
|
25
|
+
"!"
|
|
26
|
+
];
|
|
27
|
+
var CompoundFileWriteError = class extends Error {
|
|
28
|
+
constructor(message) {
|
|
29
|
+
super(message);
|
|
30
|
+
this.name = "CompoundFileWriteError";
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
function isStorage(node) {
|
|
34
|
+
return "children" in node;
|
|
35
|
+
}
|
|
36
|
+
function objectTypeOf(entry) {
|
|
37
|
+
if (entry.id === 0) return OBJECT_TYPE_ROOT;
|
|
38
|
+
return isStorage(entry.node) ? OBJECT_TYPE_STORAGE : OBJECT_TYPE_STREAM;
|
|
39
|
+
}
|
|
40
|
+
function upperCodeUnit(value, index) {
|
|
41
|
+
const unit = value.charCodeAt(index);
|
|
42
|
+
if (unit >= 55296 && unit <= 57343) return unit;
|
|
43
|
+
const upper = String.fromCharCode(unit).toUpperCase();
|
|
44
|
+
return upper.length === 1 ? upper.charCodeAt(0) : unit;
|
|
45
|
+
}
|
|
46
|
+
function compareEntryNames(left, right) {
|
|
47
|
+
if (left.length !== right.length) return left.length - right.length;
|
|
48
|
+
for (let i = 0; i < left.length; i++) {
|
|
49
|
+
const difference = upperCodeUnit(left, i) - upperCodeUnit(right, i);
|
|
50
|
+
if (difference !== 0) return difference;
|
|
51
|
+
}
|
|
52
|
+
return 0;
|
|
53
|
+
}
|
|
54
|
+
function checkedSegment(name, path) {
|
|
55
|
+
if (name.length === 0) throw new CompoundFileWriteError(`stream path ${JSON.stringify(path)} has an empty name segment; every segment must name a storage, and the last must name the stream`);
|
|
56
|
+
if (name.length > MAX_NAME_CODE_UNITS) throw new CompoundFileWriteError(`'${name}' is ${name.length} UTF-16 code points, more than the ${MAX_NAME_CODE_UNITS} a directory entry's name field holds alongside its terminating null (in stream path ${JSON.stringify(path)})`);
|
|
57
|
+
for (const illegal of ILLEGAL_NAME_CHARACTERS) if (name.includes(illegal)) throw new CompoundFileWriteError(`'${name}' holds '${illegal}', which [MS-CFB] 2.6.1 forbids in a storage or stream name (in stream path ${JSON.stringify(path)})`);
|
|
58
|
+
return name;
|
|
59
|
+
}
|
|
60
|
+
function addStream(root, path, bytes) {
|
|
61
|
+
const segments = path.split("/");
|
|
62
|
+
let storage = root;
|
|
63
|
+
let depth = 0;
|
|
64
|
+
for (const segment of segments) {
|
|
65
|
+
depth += 1;
|
|
66
|
+
const name = checkedSegment(segment, path);
|
|
67
|
+
const existing = storage.children.find((child) => compareEntryNames(child.name, name) === 0);
|
|
68
|
+
if (depth === segments.length) {
|
|
69
|
+
if (existing !== void 0) throw new CompoundFileWriteError(`stream path ${JSON.stringify(path)} collides with '${existing.name}', which the file already holds in the same storage ([MS-CFB] 2.6.4 requires siblings to have unique names)`);
|
|
70
|
+
storage.children.push({
|
|
71
|
+
name,
|
|
72
|
+
bytes
|
|
73
|
+
});
|
|
74
|
+
} else if (existing === void 0) {
|
|
75
|
+
const created = {
|
|
76
|
+
name,
|
|
77
|
+
children: []
|
|
78
|
+
};
|
|
79
|
+
storage.children.push(created);
|
|
80
|
+
storage = created;
|
|
81
|
+
} else if (isStorage(existing)) storage = existing;
|
|
82
|
+
else throw new CompoundFileWriteError(`stream path ${JSON.stringify(path)} needs '${existing.name}' to be a storage, but the file already holds a stream by that name`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function deepestDepth(count) {
|
|
86
|
+
return count === 0 ? 0 : 31 - Math.clz32(count);
|
|
87
|
+
}
|
|
88
|
+
function linkSiblings(siblings, depth, deepest) {
|
|
89
|
+
const midpoint = siblings.length >> 1;
|
|
90
|
+
const before = siblings.slice(0, midpoint);
|
|
91
|
+
const [node, ...after] = siblings.slice(midpoint);
|
|
92
|
+
if (node === void 0) return;
|
|
93
|
+
node.colour = depth === deepest && deepest > 0 ? COLOUR_RED : COLOUR_BLACK;
|
|
94
|
+
const left = linkSiblings(before, depth + 1, deepest);
|
|
95
|
+
const right = linkSiblings(after, depth + 1, deepest);
|
|
96
|
+
node.left = left === void 0 ? NOSTREAM : left.id;
|
|
97
|
+
node.right = right === void 0 ? NOSTREAM : right.id;
|
|
98
|
+
return node;
|
|
99
|
+
}
|
|
100
|
+
function planDirectory(root) {
|
|
101
|
+
const plans = [];
|
|
102
|
+
const plan = (node) => {
|
|
103
|
+
const created = {
|
|
104
|
+
id: plans.length,
|
|
105
|
+
node,
|
|
106
|
+
left: NOSTREAM,
|
|
107
|
+
right: NOSTREAM,
|
|
108
|
+
child: NOSTREAM,
|
|
109
|
+
colour: COLOUR_BLACK,
|
|
110
|
+
startSector: 0,
|
|
111
|
+
size: 0
|
|
112
|
+
};
|
|
113
|
+
plans.push(created);
|
|
114
|
+
return created;
|
|
115
|
+
};
|
|
116
|
+
const rootPlan = plan(root);
|
|
117
|
+
let frontier = [rootPlan];
|
|
118
|
+
while (frontier.length > 0) {
|
|
119
|
+
const next = [];
|
|
120
|
+
for (const parent of frontier) {
|
|
121
|
+
const node = parent.node;
|
|
122
|
+
if (!isStorage(node)) continue;
|
|
123
|
+
node.children.sort((left, right) => compareEntryNames(left.name, right.name));
|
|
124
|
+
const children = [];
|
|
125
|
+
for (const child of node.children) {
|
|
126
|
+
const childPlan = plan(child);
|
|
127
|
+
children.push(childPlan);
|
|
128
|
+
next.push(childPlan);
|
|
129
|
+
}
|
|
130
|
+
const subtree = linkSiblings(children, 0, deepestDepth(children.length));
|
|
131
|
+
parent.child = subtree === void 0 ? NOSTREAM : subtree.id;
|
|
132
|
+
}
|
|
133
|
+
frontier = next;
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
rootPlan,
|
|
137
|
+
plans
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
function writeCompoundFile(streams, options = {}) {
|
|
141
|
+
const majorVersion = options.majorVersion ?? 3;
|
|
142
|
+
const sectorShift = majorVersion === 4 ? 12 : 9;
|
|
143
|
+
const sectorSize = 1 << sectorShift;
|
|
144
|
+
const entriesPerFatSector = sectorSize / 4;
|
|
145
|
+
const entriesPerDirectorySector = sectorSize / DIRECTORY_ENTRY_SIZE;
|
|
146
|
+
const difatEntriesPerSector = entriesPerFatSector - 1;
|
|
147
|
+
const root = {
|
|
148
|
+
name: ROOT_ENTRY_NAME,
|
|
149
|
+
children: []
|
|
150
|
+
};
|
|
151
|
+
for (const { path, bytes } of streams) {
|
|
152
|
+
if (majorVersion === 3 && bytes.length > MAX_VERSION_3_STREAM_BYTES) throw new CompoundFileWriteError(`stream ${JSON.stringify(path)} is ${bytes.length} bytes, past the ${MAX_VERSION_3_STREAM_BYTES}-byte ceiling [MS-CFB] 2.6.1 puts on a version 3 stream; write the file as version 4 instead`);
|
|
153
|
+
addStream(root, path, bytes);
|
|
154
|
+
}
|
|
155
|
+
const { rootPlan, plans } = planDirectory(root);
|
|
156
|
+
const miniResident = [];
|
|
157
|
+
const fatResident = [];
|
|
158
|
+
for (const entry of plans) {
|
|
159
|
+
const node = entry.node;
|
|
160
|
+
if (isStorage(node)) continue;
|
|
161
|
+
entry.size = node.bytes.length;
|
|
162
|
+
if (node.bytes.length === 0) entry.startSector = ENDOFCHAIN;
|
|
163
|
+
else if (node.bytes.length < MINI_STREAM_CUTOFF) miniResident.push({
|
|
164
|
+
entry,
|
|
165
|
+
bytes: node.bytes
|
|
166
|
+
});
|
|
167
|
+
else fatResident.push({
|
|
168
|
+
entry,
|
|
169
|
+
bytes: node.bytes
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
let miniSectorCount = 0;
|
|
173
|
+
for (const { entry, bytes } of miniResident) {
|
|
174
|
+
entry.startSector = miniSectorCount;
|
|
175
|
+
miniSectorCount += Math.ceil(bytes.length / MINI_SECTOR_SIZE);
|
|
176
|
+
}
|
|
177
|
+
const miniStreamBytes = miniSectorCount * MINI_SECTOR_SIZE;
|
|
178
|
+
const directorySectorCount = Math.ceil(plans.length / entriesPerDirectorySector);
|
|
179
|
+
const miniStreamSectorCount = Math.ceil(miniStreamBytes / sectorSize);
|
|
180
|
+
const miniFatSectorCount = Math.ceil(miniSectorCount / entriesPerFatSector);
|
|
181
|
+
let fatStreamSectorCount = 0;
|
|
182
|
+
for (const { bytes } of fatResident) fatStreamSectorCount += Math.ceil(bytes.length / sectorSize);
|
|
183
|
+
const totalSectorsGiven = (fat, difat) => fat + difat + directorySectorCount + fatStreamSectorCount + miniStreamSectorCount + miniFatSectorCount;
|
|
184
|
+
let fatSectorCount = 1;
|
|
185
|
+
let difatSectorCount = 0;
|
|
186
|
+
for (;;) {
|
|
187
|
+
const neededFat = Math.max(1, Math.ceil(totalSectorsGiven(fatSectorCount, difatSectorCount) / entriesPerFatSector));
|
|
188
|
+
const neededDifat = neededFat <= HEADER_DIFAT_ENTRIES ? 0 : Math.ceil((neededFat - HEADER_DIFAT_ENTRIES) / difatEntriesPerSector);
|
|
189
|
+
if (neededFat === fatSectorCount && neededDifat === difatSectorCount) break;
|
|
190
|
+
fatSectorCount = neededFat;
|
|
191
|
+
difatSectorCount = neededDifat;
|
|
192
|
+
}
|
|
193
|
+
const totalSectors = totalSectorsGiven(fatSectorCount, difatSectorCount);
|
|
194
|
+
const difatStart = fatSectorCount;
|
|
195
|
+
const directoryStart = difatStart + difatSectorCount;
|
|
196
|
+
let nextSector = directoryStart + directorySectorCount;
|
|
197
|
+
for (const { entry, bytes } of fatResident) {
|
|
198
|
+
entry.startSector = nextSector;
|
|
199
|
+
nextSector += Math.ceil(bytes.length / sectorSize);
|
|
200
|
+
}
|
|
201
|
+
const miniStreamStart = nextSector;
|
|
202
|
+
nextSector += miniStreamSectorCount;
|
|
203
|
+
const miniFatStart = nextSector;
|
|
204
|
+
rootPlan.startSector = miniSectorCount === 0 ? ENDOFCHAIN : miniStreamStart;
|
|
205
|
+
rootPlan.size = miniStreamBytes;
|
|
206
|
+
const file = new Uint8Array(sectorSize * (1 + totalSectors));
|
|
207
|
+
const view = new DataView(file.buffer);
|
|
208
|
+
const putU16 = (offset, value) => {
|
|
209
|
+
view.setUint16(offset, value, true);
|
|
210
|
+
};
|
|
211
|
+
const putU32 = (offset, value) => {
|
|
212
|
+
view.setUint32(offset, value, true);
|
|
213
|
+
};
|
|
214
|
+
const sectorOffset = (sector) => (sector + 1) * sectorSize;
|
|
215
|
+
file.fill(FREESECT_FILL_BYTE, sectorOffset(0), sectorOffset(0) + fatSectorCount * sectorSize);
|
|
216
|
+
file.fill(FREESECT_FILL_BYTE, sectorOffset(miniFatStart), sectorOffset(miniFatStart) + miniFatSectorCount * sectorSize);
|
|
217
|
+
file.fill(FREESECT_FILL_BYTE, sectorOffset(difatStart), sectorOffset(difatStart) + difatSectorCount * sectorSize);
|
|
218
|
+
file.fill(FREESECT_FILL_BYTE, HEADER_DIFAT_OFFSET, 512);
|
|
219
|
+
const setFat = (sector, value) => {
|
|
220
|
+
putU32(sectorOffset(Math.floor(sector / entriesPerFatSector)) + sector % entriesPerFatSector * 4, value);
|
|
221
|
+
};
|
|
222
|
+
const chainSectors = (start, count) => {
|
|
223
|
+
for (let i = 0; i < count; i++) setFat(start + i, i === count - 1 ? ENDOFCHAIN : start + i + 1);
|
|
224
|
+
};
|
|
225
|
+
for (let i = 0; i < fatSectorCount; i++) setFat(i, FATSECT);
|
|
226
|
+
for (let i = 0; i < difatSectorCount; i++) setFat(difatStart + i, DIFSECT);
|
|
227
|
+
chainSectors(directoryStart, directorySectorCount);
|
|
228
|
+
for (const { entry, bytes } of fatResident) chainSectors(entry.startSector, Math.ceil(bytes.length / sectorSize));
|
|
229
|
+
chainSectors(miniStreamStart, miniStreamSectorCount);
|
|
230
|
+
chainSectors(miniFatStart, miniFatSectorCount);
|
|
231
|
+
for (let i = 0; i < Math.min(fatSectorCount, HEADER_DIFAT_ENTRIES); i++) putU32(HEADER_DIFAT_OFFSET + i * 4, i);
|
|
232
|
+
for (let sector = 0; sector < difatSectorCount; sector++) {
|
|
233
|
+
const base = sectorOffset(difatStart + sector);
|
|
234
|
+
for (let i = 0; i < difatEntriesPerSector; i++) {
|
|
235
|
+
const fatIndex = HEADER_DIFAT_ENTRIES + sector * difatEntriesPerSector + i;
|
|
236
|
+
if (fatIndex < fatSectorCount) putU32(base + i * 4, fatIndex);
|
|
237
|
+
}
|
|
238
|
+
putU32(base + difatEntriesPerSector * 4, sector === difatSectorCount - 1 ? ENDOFCHAIN : difatStart + sector + 1);
|
|
239
|
+
}
|
|
240
|
+
const setMiniFat = (miniSector, value) => {
|
|
241
|
+
putU32(sectorOffset(miniFatStart + Math.floor(miniSector / entriesPerFatSector)) + miniSector % entriesPerFatSector * 4, value);
|
|
242
|
+
};
|
|
243
|
+
for (const { entry, bytes } of miniResident) {
|
|
244
|
+
const count = Math.ceil(bytes.length / MINI_SECTOR_SIZE);
|
|
245
|
+
for (let i = 0; i < count; i++) setMiniFat(entry.startSector + i, i === count - 1 ? ENDOFCHAIN : entry.startSector + i + 1);
|
|
246
|
+
}
|
|
247
|
+
for (const { entry, bytes } of fatResident) file.set(bytes, sectorOffset(entry.startSector));
|
|
248
|
+
for (const { entry, bytes } of miniResident) file.set(bytes, sectorOffset(miniStreamStart) + entry.startSector * MINI_SECTOR_SIZE);
|
|
249
|
+
const entryOffset = (id) => sectorOffset(directoryStart + Math.floor(id / entriesPerDirectorySector)) + id % entriesPerDirectorySector * DIRECTORY_ENTRY_SIZE;
|
|
250
|
+
for (const entry of plans) {
|
|
251
|
+
const base = entryOffset(entry.id);
|
|
252
|
+
const name = entry.node.name;
|
|
253
|
+
for (let i = 0; i < name.length; i++) putU16(base + i * 2, name.charCodeAt(i));
|
|
254
|
+
putU16(base + 64, (name.length + 1) * 2);
|
|
255
|
+
view.setUint8(base + 66, objectTypeOf(entry));
|
|
256
|
+
view.setUint8(base + 67, entry.colour);
|
|
257
|
+
putU32(base + 68, entry.left);
|
|
258
|
+
putU32(base + 72, entry.right);
|
|
259
|
+
putU32(base + 76, entry.child);
|
|
260
|
+
putU32(base + 116, entry.startSector);
|
|
261
|
+
putU32(base + 120, entry.size >>> 0);
|
|
262
|
+
putU32(base + 124, Math.floor(entry.size / 4294967296));
|
|
263
|
+
}
|
|
264
|
+
for (let id = plans.length; id < directorySectorCount * entriesPerDirectorySector; id++) {
|
|
265
|
+
const base = entryOffset(id);
|
|
266
|
+
putU32(base + 68, NOSTREAM);
|
|
267
|
+
putU32(base + 72, NOSTREAM);
|
|
268
|
+
putU32(base + 76, NOSTREAM);
|
|
269
|
+
}
|
|
270
|
+
file.set([
|
|
271
|
+
208,
|
|
272
|
+
207,
|
|
273
|
+
17,
|
|
274
|
+
224,
|
|
275
|
+
161,
|
|
276
|
+
177,
|
|
277
|
+
26,
|
|
278
|
+
225
|
|
279
|
+
], 0);
|
|
280
|
+
putU16(24, 62);
|
|
281
|
+
putU16(26, majorVersion);
|
|
282
|
+
putU16(28, 65534);
|
|
283
|
+
putU16(30, sectorShift);
|
|
284
|
+
putU16(32, MINI_SECTOR_SHIFT);
|
|
285
|
+
putU32(40, majorVersion === 3 ? 0 : directorySectorCount);
|
|
286
|
+
putU32(44, fatSectorCount);
|
|
287
|
+
putU32(48, directoryStart);
|
|
288
|
+
putU32(56, MINI_STREAM_CUTOFF);
|
|
289
|
+
putU32(60, miniFatSectorCount === 0 ? ENDOFCHAIN : miniFatStart);
|
|
290
|
+
putU32(64, miniFatSectorCount);
|
|
291
|
+
putU32(68, difatSectorCount === 0 ? ENDOFCHAIN : difatStart);
|
|
292
|
+
putU32(72, difatSectorCount);
|
|
293
|
+
return file;
|
|
294
|
+
}
|
|
295
|
+
//#endregion
|
|
296
|
+
exports.CompoundFileWriteError = CompoundFileWriteError;
|
|
297
|
+
exports.writeCompoundFile = writeCompoundFile;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { CompoundFileStream } from "./read.cjs";
|
|
2
|
+
//#region src/cfb/write.d.ts
|
|
3
|
+
declare class CompoundFileWriteError extends Error {
|
|
4
|
+
constructor(message: string);
|
|
5
|
+
}
|
|
6
|
+
interface WriteCompoundFileOptions {
|
|
7
|
+
readonly majorVersion?: 3 | 4;
|
|
8
|
+
}
|
|
9
|
+
declare function writeCompoundFile(streams: readonly CompoundFileStream[], options?: WriteCompoundFileOptions): Uint8Array<ArrayBuffer>;
|
|
10
|
+
//#endregion
|
|
11
|
+
export { CompoundFileWriteError, WriteCompoundFileOptions, writeCompoundFile };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { CompoundFileStream } from "./read.js";
|
|
2
|
+
//#region src/cfb/write.d.ts
|
|
3
|
+
declare class CompoundFileWriteError extends Error {
|
|
4
|
+
constructor(message: string);
|
|
5
|
+
}
|
|
6
|
+
interface WriteCompoundFileOptions {
|
|
7
|
+
readonly majorVersion?: 3 | 4;
|
|
8
|
+
}
|
|
9
|
+
declare function writeCompoundFile(streams: readonly CompoundFileStream[], options?: WriteCompoundFileOptions): Uint8Array<ArrayBuffer>;
|
|
10
|
+
//#endregion
|
|
11
|
+
export { CompoundFileWriteError, WriteCompoundFileOptions, writeCompoundFile };
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
//#region src/cfb/write.ts
|
|
2
|
+
const ENDOFCHAIN = 4294967294;
|
|
3
|
+
const FATSECT = 4294967293;
|
|
4
|
+
const DIFSECT = 4294967292;
|
|
5
|
+
const NOSTREAM = 4294967295;
|
|
6
|
+
const FREESECT_FILL_BYTE = 255;
|
|
7
|
+
const HEADER_DIFAT_ENTRIES = 109;
|
|
8
|
+
const HEADER_DIFAT_OFFSET = 76;
|
|
9
|
+
const DIRECTORY_ENTRY_SIZE = 128;
|
|
10
|
+
const MAX_NAME_CODE_UNITS = 31;
|
|
11
|
+
const MINI_SECTOR_SHIFT = 6;
|
|
12
|
+
const MINI_SECTOR_SIZE = 64;
|
|
13
|
+
const MINI_STREAM_CUTOFF = 4096;
|
|
14
|
+
const OBJECT_TYPE_STORAGE = 1;
|
|
15
|
+
const OBJECT_TYPE_STREAM = 2;
|
|
16
|
+
const OBJECT_TYPE_ROOT = 5;
|
|
17
|
+
const COLOUR_RED = 0;
|
|
18
|
+
const COLOUR_BLACK = 1;
|
|
19
|
+
const ROOT_ENTRY_NAME = "Root Entry";
|
|
20
|
+
const MAX_VERSION_3_STREAM_BYTES = 2147483648;
|
|
21
|
+
const ILLEGAL_NAME_CHARACTERS = [
|
|
22
|
+
"\\",
|
|
23
|
+
":",
|
|
24
|
+
"!"
|
|
25
|
+
];
|
|
26
|
+
var CompoundFileWriteError = class extends Error {
|
|
27
|
+
constructor(message) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.name = "CompoundFileWriteError";
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
function isStorage(node) {
|
|
33
|
+
return "children" in node;
|
|
34
|
+
}
|
|
35
|
+
function objectTypeOf(entry) {
|
|
36
|
+
if (entry.id === 0) return OBJECT_TYPE_ROOT;
|
|
37
|
+
return isStorage(entry.node) ? OBJECT_TYPE_STORAGE : OBJECT_TYPE_STREAM;
|
|
38
|
+
}
|
|
39
|
+
function upperCodeUnit(value, index) {
|
|
40
|
+
const unit = value.charCodeAt(index);
|
|
41
|
+
if (unit >= 55296 && unit <= 57343) return unit;
|
|
42
|
+
const upper = String.fromCharCode(unit).toUpperCase();
|
|
43
|
+
return upper.length === 1 ? upper.charCodeAt(0) : unit;
|
|
44
|
+
}
|
|
45
|
+
function compareEntryNames(left, right) {
|
|
46
|
+
if (left.length !== right.length) return left.length - right.length;
|
|
47
|
+
for (let i = 0; i < left.length; i++) {
|
|
48
|
+
const difference = upperCodeUnit(left, i) - upperCodeUnit(right, i);
|
|
49
|
+
if (difference !== 0) return difference;
|
|
50
|
+
}
|
|
51
|
+
return 0;
|
|
52
|
+
}
|
|
53
|
+
function checkedSegment(name, path) {
|
|
54
|
+
if (name.length === 0) throw new CompoundFileWriteError(`stream path ${JSON.stringify(path)} has an empty name segment; every segment must name a storage, and the last must name the stream`);
|
|
55
|
+
if (name.length > MAX_NAME_CODE_UNITS) throw new CompoundFileWriteError(`'${name}' is ${name.length} UTF-16 code points, more than the ${MAX_NAME_CODE_UNITS} a directory entry's name field holds alongside its terminating null (in stream path ${JSON.stringify(path)})`);
|
|
56
|
+
for (const illegal of ILLEGAL_NAME_CHARACTERS) if (name.includes(illegal)) throw new CompoundFileWriteError(`'${name}' holds '${illegal}', which [MS-CFB] 2.6.1 forbids in a storage or stream name (in stream path ${JSON.stringify(path)})`);
|
|
57
|
+
return name;
|
|
58
|
+
}
|
|
59
|
+
function addStream(root, path, bytes) {
|
|
60
|
+
const segments = path.split("/");
|
|
61
|
+
let storage = root;
|
|
62
|
+
let depth = 0;
|
|
63
|
+
for (const segment of segments) {
|
|
64
|
+
depth += 1;
|
|
65
|
+
const name = checkedSegment(segment, path);
|
|
66
|
+
const existing = storage.children.find((child) => compareEntryNames(child.name, name) === 0);
|
|
67
|
+
if (depth === segments.length) {
|
|
68
|
+
if (existing !== void 0) throw new CompoundFileWriteError(`stream path ${JSON.stringify(path)} collides with '${existing.name}', which the file already holds in the same storage ([MS-CFB] 2.6.4 requires siblings to have unique names)`);
|
|
69
|
+
storage.children.push({
|
|
70
|
+
name,
|
|
71
|
+
bytes
|
|
72
|
+
});
|
|
73
|
+
} else if (existing === void 0) {
|
|
74
|
+
const created = {
|
|
75
|
+
name,
|
|
76
|
+
children: []
|
|
77
|
+
};
|
|
78
|
+
storage.children.push(created);
|
|
79
|
+
storage = created;
|
|
80
|
+
} else if (isStorage(existing)) storage = existing;
|
|
81
|
+
else throw new CompoundFileWriteError(`stream path ${JSON.stringify(path)} needs '${existing.name}' to be a storage, but the file already holds a stream by that name`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function deepestDepth(count) {
|
|
85
|
+
return count === 0 ? 0 : 31 - Math.clz32(count);
|
|
86
|
+
}
|
|
87
|
+
function linkSiblings(siblings, depth, deepest) {
|
|
88
|
+
const midpoint = siblings.length >> 1;
|
|
89
|
+
const before = siblings.slice(0, midpoint);
|
|
90
|
+
const [node, ...after] = siblings.slice(midpoint);
|
|
91
|
+
if (node === void 0) return;
|
|
92
|
+
node.colour = depth === deepest && deepest > 0 ? COLOUR_RED : COLOUR_BLACK;
|
|
93
|
+
const left = linkSiblings(before, depth + 1, deepest);
|
|
94
|
+
const right = linkSiblings(after, depth + 1, deepest);
|
|
95
|
+
node.left = left === void 0 ? NOSTREAM : left.id;
|
|
96
|
+
node.right = right === void 0 ? NOSTREAM : right.id;
|
|
97
|
+
return node;
|
|
98
|
+
}
|
|
99
|
+
function planDirectory(root) {
|
|
100
|
+
const plans = [];
|
|
101
|
+
const plan = (node) => {
|
|
102
|
+
const created = {
|
|
103
|
+
id: plans.length,
|
|
104
|
+
node,
|
|
105
|
+
left: NOSTREAM,
|
|
106
|
+
right: NOSTREAM,
|
|
107
|
+
child: NOSTREAM,
|
|
108
|
+
colour: COLOUR_BLACK,
|
|
109
|
+
startSector: 0,
|
|
110
|
+
size: 0
|
|
111
|
+
};
|
|
112
|
+
plans.push(created);
|
|
113
|
+
return created;
|
|
114
|
+
};
|
|
115
|
+
const rootPlan = plan(root);
|
|
116
|
+
let frontier = [rootPlan];
|
|
117
|
+
while (frontier.length > 0) {
|
|
118
|
+
const next = [];
|
|
119
|
+
for (const parent of frontier) {
|
|
120
|
+
const node = parent.node;
|
|
121
|
+
if (!isStorage(node)) continue;
|
|
122
|
+
node.children.sort((left, right) => compareEntryNames(left.name, right.name));
|
|
123
|
+
const children = [];
|
|
124
|
+
for (const child of node.children) {
|
|
125
|
+
const childPlan = plan(child);
|
|
126
|
+
children.push(childPlan);
|
|
127
|
+
next.push(childPlan);
|
|
128
|
+
}
|
|
129
|
+
const subtree = linkSiblings(children, 0, deepestDepth(children.length));
|
|
130
|
+
parent.child = subtree === void 0 ? NOSTREAM : subtree.id;
|
|
131
|
+
}
|
|
132
|
+
frontier = next;
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
rootPlan,
|
|
136
|
+
plans
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
function writeCompoundFile(streams, options = {}) {
|
|
140
|
+
const majorVersion = options.majorVersion ?? 3;
|
|
141
|
+
const sectorShift = majorVersion === 4 ? 12 : 9;
|
|
142
|
+
const sectorSize = 1 << sectorShift;
|
|
143
|
+
const entriesPerFatSector = sectorSize / 4;
|
|
144
|
+
const entriesPerDirectorySector = sectorSize / DIRECTORY_ENTRY_SIZE;
|
|
145
|
+
const difatEntriesPerSector = entriesPerFatSector - 1;
|
|
146
|
+
const root = {
|
|
147
|
+
name: ROOT_ENTRY_NAME,
|
|
148
|
+
children: []
|
|
149
|
+
};
|
|
150
|
+
for (const { path, bytes } of streams) {
|
|
151
|
+
if (majorVersion === 3 && bytes.length > MAX_VERSION_3_STREAM_BYTES) throw new CompoundFileWriteError(`stream ${JSON.stringify(path)} is ${bytes.length} bytes, past the ${MAX_VERSION_3_STREAM_BYTES}-byte ceiling [MS-CFB] 2.6.1 puts on a version 3 stream; write the file as version 4 instead`);
|
|
152
|
+
addStream(root, path, bytes);
|
|
153
|
+
}
|
|
154
|
+
const { rootPlan, plans } = planDirectory(root);
|
|
155
|
+
const miniResident = [];
|
|
156
|
+
const fatResident = [];
|
|
157
|
+
for (const entry of plans) {
|
|
158
|
+
const node = entry.node;
|
|
159
|
+
if (isStorage(node)) continue;
|
|
160
|
+
entry.size = node.bytes.length;
|
|
161
|
+
if (node.bytes.length === 0) entry.startSector = ENDOFCHAIN;
|
|
162
|
+
else if (node.bytes.length < MINI_STREAM_CUTOFF) miniResident.push({
|
|
163
|
+
entry,
|
|
164
|
+
bytes: node.bytes
|
|
165
|
+
});
|
|
166
|
+
else fatResident.push({
|
|
167
|
+
entry,
|
|
168
|
+
bytes: node.bytes
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
let miniSectorCount = 0;
|
|
172
|
+
for (const { entry, bytes } of miniResident) {
|
|
173
|
+
entry.startSector = miniSectorCount;
|
|
174
|
+
miniSectorCount += Math.ceil(bytes.length / MINI_SECTOR_SIZE);
|
|
175
|
+
}
|
|
176
|
+
const miniStreamBytes = miniSectorCount * MINI_SECTOR_SIZE;
|
|
177
|
+
const directorySectorCount = Math.ceil(plans.length / entriesPerDirectorySector);
|
|
178
|
+
const miniStreamSectorCount = Math.ceil(miniStreamBytes / sectorSize);
|
|
179
|
+
const miniFatSectorCount = Math.ceil(miniSectorCount / entriesPerFatSector);
|
|
180
|
+
let fatStreamSectorCount = 0;
|
|
181
|
+
for (const { bytes } of fatResident) fatStreamSectorCount += Math.ceil(bytes.length / sectorSize);
|
|
182
|
+
const totalSectorsGiven = (fat, difat) => fat + difat + directorySectorCount + fatStreamSectorCount + miniStreamSectorCount + miniFatSectorCount;
|
|
183
|
+
let fatSectorCount = 1;
|
|
184
|
+
let difatSectorCount = 0;
|
|
185
|
+
for (;;) {
|
|
186
|
+
const neededFat = Math.max(1, Math.ceil(totalSectorsGiven(fatSectorCount, difatSectorCount) / entriesPerFatSector));
|
|
187
|
+
const neededDifat = neededFat <= HEADER_DIFAT_ENTRIES ? 0 : Math.ceil((neededFat - HEADER_DIFAT_ENTRIES) / difatEntriesPerSector);
|
|
188
|
+
if (neededFat === fatSectorCount && neededDifat === difatSectorCount) break;
|
|
189
|
+
fatSectorCount = neededFat;
|
|
190
|
+
difatSectorCount = neededDifat;
|
|
191
|
+
}
|
|
192
|
+
const totalSectors = totalSectorsGiven(fatSectorCount, difatSectorCount);
|
|
193
|
+
const difatStart = fatSectorCount;
|
|
194
|
+
const directoryStart = difatStart + difatSectorCount;
|
|
195
|
+
let nextSector = directoryStart + directorySectorCount;
|
|
196
|
+
for (const { entry, bytes } of fatResident) {
|
|
197
|
+
entry.startSector = nextSector;
|
|
198
|
+
nextSector += Math.ceil(bytes.length / sectorSize);
|
|
199
|
+
}
|
|
200
|
+
const miniStreamStart = nextSector;
|
|
201
|
+
nextSector += miniStreamSectorCount;
|
|
202
|
+
const miniFatStart = nextSector;
|
|
203
|
+
rootPlan.startSector = miniSectorCount === 0 ? ENDOFCHAIN : miniStreamStart;
|
|
204
|
+
rootPlan.size = miniStreamBytes;
|
|
205
|
+
const file = new Uint8Array(sectorSize * (1 + totalSectors));
|
|
206
|
+
const view = new DataView(file.buffer);
|
|
207
|
+
const putU16 = (offset, value) => {
|
|
208
|
+
view.setUint16(offset, value, true);
|
|
209
|
+
};
|
|
210
|
+
const putU32 = (offset, value) => {
|
|
211
|
+
view.setUint32(offset, value, true);
|
|
212
|
+
};
|
|
213
|
+
const sectorOffset = (sector) => (sector + 1) * sectorSize;
|
|
214
|
+
file.fill(FREESECT_FILL_BYTE, sectorOffset(0), sectorOffset(0) + fatSectorCount * sectorSize);
|
|
215
|
+
file.fill(FREESECT_FILL_BYTE, sectorOffset(miniFatStart), sectorOffset(miniFatStart) + miniFatSectorCount * sectorSize);
|
|
216
|
+
file.fill(FREESECT_FILL_BYTE, sectorOffset(difatStart), sectorOffset(difatStart) + difatSectorCount * sectorSize);
|
|
217
|
+
file.fill(FREESECT_FILL_BYTE, HEADER_DIFAT_OFFSET, 512);
|
|
218
|
+
const setFat = (sector, value) => {
|
|
219
|
+
putU32(sectorOffset(Math.floor(sector / entriesPerFatSector)) + sector % entriesPerFatSector * 4, value);
|
|
220
|
+
};
|
|
221
|
+
const chainSectors = (start, count) => {
|
|
222
|
+
for (let i = 0; i < count; i++) setFat(start + i, i === count - 1 ? ENDOFCHAIN : start + i + 1);
|
|
223
|
+
};
|
|
224
|
+
for (let i = 0; i < fatSectorCount; i++) setFat(i, FATSECT);
|
|
225
|
+
for (let i = 0; i < difatSectorCount; i++) setFat(difatStart + i, DIFSECT);
|
|
226
|
+
chainSectors(directoryStart, directorySectorCount);
|
|
227
|
+
for (const { entry, bytes } of fatResident) chainSectors(entry.startSector, Math.ceil(bytes.length / sectorSize));
|
|
228
|
+
chainSectors(miniStreamStart, miniStreamSectorCount);
|
|
229
|
+
chainSectors(miniFatStart, miniFatSectorCount);
|
|
230
|
+
for (let i = 0; i < Math.min(fatSectorCount, HEADER_DIFAT_ENTRIES); i++) putU32(HEADER_DIFAT_OFFSET + i * 4, i);
|
|
231
|
+
for (let sector = 0; sector < difatSectorCount; sector++) {
|
|
232
|
+
const base = sectorOffset(difatStart + sector);
|
|
233
|
+
for (let i = 0; i < difatEntriesPerSector; i++) {
|
|
234
|
+
const fatIndex = HEADER_DIFAT_ENTRIES + sector * difatEntriesPerSector + i;
|
|
235
|
+
if (fatIndex < fatSectorCount) putU32(base + i * 4, fatIndex);
|
|
236
|
+
}
|
|
237
|
+
putU32(base + difatEntriesPerSector * 4, sector === difatSectorCount - 1 ? ENDOFCHAIN : difatStart + sector + 1);
|
|
238
|
+
}
|
|
239
|
+
const setMiniFat = (miniSector, value) => {
|
|
240
|
+
putU32(sectorOffset(miniFatStart + Math.floor(miniSector / entriesPerFatSector)) + miniSector % entriesPerFatSector * 4, value);
|
|
241
|
+
};
|
|
242
|
+
for (const { entry, bytes } of miniResident) {
|
|
243
|
+
const count = Math.ceil(bytes.length / MINI_SECTOR_SIZE);
|
|
244
|
+
for (let i = 0; i < count; i++) setMiniFat(entry.startSector + i, i === count - 1 ? ENDOFCHAIN : entry.startSector + i + 1);
|
|
245
|
+
}
|
|
246
|
+
for (const { entry, bytes } of fatResident) file.set(bytes, sectorOffset(entry.startSector));
|
|
247
|
+
for (const { entry, bytes } of miniResident) file.set(bytes, sectorOffset(miniStreamStart) + entry.startSector * MINI_SECTOR_SIZE);
|
|
248
|
+
const entryOffset = (id) => sectorOffset(directoryStart + Math.floor(id / entriesPerDirectorySector)) + id % entriesPerDirectorySector * DIRECTORY_ENTRY_SIZE;
|
|
249
|
+
for (const entry of plans) {
|
|
250
|
+
const base = entryOffset(entry.id);
|
|
251
|
+
const name = entry.node.name;
|
|
252
|
+
for (let i = 0; i < name.length; i++) putU16(base + i * 2, name.charCodeAt(i));
|
|
253
|
+
putU16(base + 64, (name.length + 1) * 2);
|
|
254
|
+
view.setUint8(base + 66, objectTypeOf(entry));
|
|
255
|
+
view.setUint8(base + 67, entry.colour);
|
|
256
|
+
putU32(base + 68, entry.left);
|
|
257
|
+
putU32(base + 72, entry.right);
|
|
258
|
+
putU32(base + 76, entry.child);
|
|
259
|
+
putU32(base + 116, entry.startSector);
|
|
260
|
+
putU32(base + 120, entry.size >>> 0);
|
|
261
|
+
putU32(base + 124, Math.floor(entry.size / 4294967296));
|
|
262
|
+
}
|
|
263
|
+
for (let id = plans.length; id < directorySectorCount * entriesPerDirectorySector; id++) {
|
|
264
|
+
const base = entryOffset(id);
|
|
265
|
+
putU32(base + 68, NOSTREAM);
|
|
266
|
+
putU32(base + 72, NOSTREAM);
|
|
267
|
+
putU32(base + 76, NOSTREAM);
|
|
268
|
+
}
|
|
269
|
+
file.set([
|
|
270
|
+
208,
|
|
271
|
+
207,
|
|
272
|
+
17,
|
|
273
|
+
224,
|
|
274
|
+
161,
|
|
275
|
+
177,
|
|
276
|
+
26,
|
|
277
|
+
225
|
|
278
|
+
], 0);
|
|
279
|
+
putU16(24, 62);
|
|
280
|
+
putU16(26, majorVersion);
|
|
281
|
+
putU16(28, 65534);
|
|
282
|
+
putU16(30, sectorShift);
|
|
283
|
+
putU16(32, MINI_SECTOR_SHIFT);
|
|
284
|
+
putU32(40, majorVersion === 3 ? 0 : directorySectorCount);
|
|
285
|
+
putU32(44, fatSectorCount);
|
|
286
|
+
putU32(48, directoryStart);
|
|
287
|
+
putU32(56, MINI_STREAM_CUTOFF);
|
|
288
|
+
putU32(60, miniFatSectorCount === 0 ? ENDOFCHAIN : miniFatStart);
|
|
289
|
+
putU32(64, miniFatSectorCount);
|
|
290
|
+
putU32(68, difatSectorCount === 0 ? ENDOFCHAIN : difatStart);
|
|
291
|
+
putU32(72, difatSectorCount);
|
|
292
|
+
return file;
|
|
293
|
+
}
|
|
294
|
+
//#endregion
|
|
295
|
+
export { CompoundFileWriteError, writeCompoundFile };
|
package/dist/index.cjs
CHANGED
|
@@ -2,11 +2,13 @@ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
|
2
2
|
const require_cfb_detect = require("./cfb/detect.cjs");
|
|
3
3
|
const require_cfb_ole_package = require("./cfb/ole-package.cjs");
|
|
4
4
|
const require_cfb_read = require("./cfb/read.cjs");
|
|
5
|
+
const require_cfb_write = require("./cfb/write.cjs");
|
|
5
6
|
const require_zip_container = require("./zip/container.cjs");
|
|
6
7
|
const require_zip_detect = require("./zip/detect.cjs");
|
|
7
8
|
const require_zip_walk = require("./zip/walk.cjs");
|
|
8
9
|
exports.ArchiveWalkLimitError = require_zip_walk.ArchiveWalkLimitError;
|
|
9
10
|
exports.CompoundFileFormatError = require_cfb_read.CompoundFileFormatError;
|
|
11
|
+
exports.CompoundFileWriteError = require_cfb_write.CompoundFileWriteError;
|
|
10
12
|
exports.MAX_CFB_TOTAL_STREAM_BYTES = require_cfb_read.MAX_CFB_TOTAL_STREAM_BYTES;
|
|
11
13
|
exports.MAX_WALK_DEPTH = require_zip_walk.MAX_WALK_DEPTH;
|
|
12
14
|
exports.MAX_WALK_TOTAL_BYTES = require_zip_walk.MAX_WALK_TOTAL_BYTES;
|
|
@@ -18,4 +20,5 @@ exports.readCompoundFile = require_cfb_read.readCompoundFile;
|
|
|
18
20
|
exports.readOlePackage = require_cfb_ole_package.readOlePackage;
|
|
19
21
|
exports.unzipPackage = require_zip_container.unzipPackage;
|
|
20
22
|
exports.walkArchive = require_zip_walk.walkArchive;
|
|
23
|
+
exports.writeCompoundFile = require_cfb_write.writeCompoundFile;
|
|
21
24
|
exports.zipPackage = require_zip_container.zipPackage;
|
package/dist/index.d.cts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { isCompoundFile } from "./cfb/detect.cjs";
|
|
2
2
|
import { OlePackage, OlePackageFormatError, readOlePackage } from "./cfb/ole-package.cjs";
|
|
3
3
|
import { CompoundFileFormatError, CompoundFileStream, MAX_CFB_TOTAL_STREAM_BYTES, ReadCompoundFileOptions, readCompoundFile } from "./cfb/read.cjs";
|
|
4
|
+
import { CompoundFileWriteError, WriteCompoundFileOptions, writeCompoundFile } from "./cfb/write.cjs";
|
|
4
5
|
import { ZipEntry, unzipPackage, zipPackage } from "./zip/container.cjs";
|
|
5
6
|
import { ArchiveFormat, detectArchiveFormat, isZipArchive } from "./zip/detect.cjs";
|
|
6
7
|
import { ArchiveWalkEntry, ArchiveWalkLimit, ArchiveWalkLimitError, MAX_WALK_DEPTH, MAX_WALK_TOTAL_BYTES, WalkArchiveOptions, walkArchive } from "./zip/walk.cjs";
|
|
7
|
-
export { ArchiveFormat, ArchiveWalkEntry, ArchiveWalkLimit, ArchiveWalkLimitError, CompoundFileFormatError, CompoundFileStream, MAX_CFB_TOTAL_STREAM_BYTES, MAX_WALK_DEPTH, MAX_WALK_TOTAL_BYTES, OlePackage, OlePackageFormatError, ReadCompoundFileOptions, WalkArchiveOptions, ZipEntry, detectArchiveFormat, isCompoundFile, isZipArchive, readCompoundFile, readOlePackage, unzipPackage, walkArchive, zipPackage };
|
|
8
|
+
export { ArchiveFormat, ArchiveWalkEntry, ArchiveWalkLimit, ArchiveWalkLimitError, CompoundFileFormatError, CompoundFileStream, CompoundFileWriteError, MAX_CFB_TOTAL_STREAM_BYTES, MAX_WALK_DEPTH, MAX_WALK_TOTAL_BYTES, OlePackage, OlePackageFormatError, ReadCompoundFileOptions, WalkArchiveOptions, WriteCompoundFileOptions, ZipEntry, detectArchiveFormat, isCompoundFile, isZipArchive, readCompoundFile, readOlePackage, unzipPackage, walkArchive, writeCompoundFile, zipPackage };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { isCompoundFile } from "./cfb/detect.js";
|
|
2
2
|
import { OlePackage, OlePackageFormatError, readOlePackage } from "./cfb/ole-package.js";
|
|
3
3
|
import { CompoundFileFormatError, CompoundFileStream, MAX_CFB_TOTAL_STREAM_BYTES, ReadCompoundFileOptions, readCompoundFile } from "./cfb/read.js";
|
|
4
|
+
import { CompoundFileWriteError, WriteCompoundFileOptions, writeCompoundFile } from "./cfb/write.js";
|
|
4
5
|
import { ZipEntry, unzipPackage, zipPackage } from "./zip/container.js";
|
|
5
6
|
import { ArchiveFormat, detectArchiveFormat, isZipArchive } from "./zip/detect.js";
|
|
6
7
|
import { ArchiveWalkEntry, ArchiveWalkLimit, ArchiveWalkLimitError, MAX_WALK_DEPTH, MAX_WALK_TOTAL_BYTES, WalkArchiveOptions, walkArchive } from "./zip/walk.js";
|
|
7
|
-
export { ArchiveFormat, ArchiveWalkEntry, ArchiveWalkLimit, ArchiveWalkLimitError, CompoundFileFormatError, CompoundFileStream, MAX_CFB_TOTAL_STREAM_BYTES, MAX_WALK_DEPTH, MAX_WALK_TOTAL_BYTES, OlePackage, OlePackageFormatError, ReadCompoundFileOptions, WalkArchiveOptions, ZipEntry, detectArchiveFormat, isCompoundFile, isZipArchive, readCompoundFile, readOlePackage, unzipPackage, walkArchive, zipPackage };
|
|
8
|
+
export { ArchiveFormat, ArchiveWalkEntry, ArchiveWalkLimit, ArchiveWalkLimitError, CompoundFileFormatError, CompoundFileStream, CompoundFileWriteError, MAX_CFB_TOTAL_STREAM_BYTES, MAX_WALK_DEPTH, MAX_WALK_TOTAL_BYTES, OlePackage, OlePackageFormatError, ReadCompoundFileOptions, WalkArchiveOptions, WriteCompoundFileOptions, ZipEntry, detectArchiveFormat, isCompoundFile, isZipArchive, readCompoundFile, readOlePackage, unzipPackage, walkArchive, writeCompoundFile, zipPackage };
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { isCompoundFile } from "./cfb/detect.js";
|
|
2
2
|
import { OlePackageFormatError, readOlePackage } from "./cfb/ole-package.js";
|
|
3
3
|
import { CompoundFileFormatError, MAX_CFB_TOTAL_STREAM_BYTES, readCompoundFile } from "./cfb/read.js";
|
|
4
|
+
import { CompoundFileWriteError, writeCompoundFile } from "./cfb/write.js";
|
|
4
5
|
import { unzipPackage, zipPackage } from "./zip/container.js";
|
|
5
6
|
import { detectArchiveFormat, isZipArchive } from "./zip/detect.js";
|
|
6
7
|
import { ArchiveWalkLimitError, MAX_WALK_DEPTH, MAX_WALK_TOTAL_BYTES, walkArchive } from "./zip/walk.js";
|
|
7
|
-
export { ArchiveWalkLimitError, CompoundFileFormatError, MAX_CFB_TOTAL_STREAM_BYTES, MAX_WALK_DEPTH, MAX_WALK_TOTAL_BYTES, OlePackageFormatError, detectArchiveFormat, isCompoundFile, isZipArchive, readCompoundFile, readOlePackage, unzipPackage, walkArchive, zipPackage };
|
|
8
|
+
export { ArchiveWalkLimitError, CompoundFileFormatError, CompoundFileWriteError, MAX_CFB_TOTAL_STREAM_BYTES, MAX_WALK_DEPTH, MAX_WALK_TOTAL_BYTES, OlePackageFormatError, detectArchiveFormat, isCompoundFile, isZipArchive, readCompoundFile, readOlePackage, unzipPackage, walkArchive, writeCompoundFile, zipPackage };
|
package/dist/zip/detect.d.cts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
//#region src/zip/detect.d.ts
|
|
2
|
-
type ArchiveFormat =
|
|
2
|
+
type ArchiveFormat = "zip" | "cfb" | "unknown";
|
|
3
3
|
declare function isZipArchive(bytes: Uint8Array): boolean;
|
|
4
4
|
declare function detectArchiveFormat(bytes: Uint8Array): ArchiveFormat;
|
|
5
5
|
//#endregion
|
package/dist/zip/detect.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
//#region src/zip/detect.d.ts
|
|
2
|
-
type ArchiveFormat =
|
|
2
|
+
type ArchiveFormat = "zip" | "cfb" | "unknown";
|
|
3
3
|
declare function isZipArchive(bytes: Uint8Array): boolean;
|
|
4
4
|
declare function detectArchiveFormat(bytes: Uint8Array): ArchiveFormat;
|
|
5
5
|
//#endregion
|
package/dist/zip/walk.d.cts
CHANGED
|
@@ -6,7 +6,7 @@ interface ArchiveWalkEntry {
|
|
|
6
6
|
readonly ancestors: readonly string[];
|
|
7
7
|
readonly bytes: Uint8Array<ArrayBuffer>;
|
|
8
8
|
}
|
|
9
|
-
type ArchiveWalkLimit =
|
|
9
|
+
type ArchiveWalkLimit = "depth" | "total-bytes";
|
|
10
10
|
declare class ArchiveWalkLimitError extends Error {
|
|
11
11
|
readonly limit: ArchiveWalkLimit;
|
|
12
12
|
constructor(limit: ArchiveWalkLimit, message: string);
|
package/dist/zip/walk.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ interface ArchiveWalkEntry {
|
|
|
6
6
|
readonly ancestors: readonly string[];
|
|
7
7
|
readonly bytes: Uint8Array<ArrayBuffer>;
|
|
8
8
|
}
|
|
9
|
-
type ArchiveWalkLimit =
|
|
9
|
+
type ArchiveWalkLimit = "depth" | "total-bytes";
|
|
10
10
|
declare class ArchiveWalkLimitError extends Error {
|
|
11
11
|
readonly limit: ArchiveWalkLimit;
|
|
12
12
|
constructor(limit: ArchiveWalkLimit, message: string);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "archive-codec",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "ZIP-in-ZIP recursive walking with depth and cumulative decompressed-size guards, plus bounded classic OLE compound-file ([MS-CFB]) reading - zero document-format knowledge, the archive and container utility package for the documents.js family.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -72,22 +72,13 @@
|
|
|
72
72
|
"devDependencies": {
|
|
73
73
|
"@arethetypeswrong/cli": "^0.18.5",
|
|
74
74
|
"@cloudflare/vitest-pool-workers": "^0.20.1",
|
|
75
|
-
"@commitlint/cli": "^21.2.1",
|
|
76
|
-
"@commitlint/config-conventional": "^21.2.0",
|
|
77
|
-
"@eslint/js": "^10.0.1",
|
|
78
|
-
"@semantic-release/changelog": "^7.0.0",
|
|
79
|
-
"@semantic-release/git": "^11.0.1",
|
|
80
75
|
"@types/node": "^26.1.2",
|
|
81
76
|
"eslint": "^10.8.0",
|
|
82
|
-
"globals": "^17.8.0",
|
|
83
77
|
"husky": "^9.1.7",
|
|
84
|
-
"lint-staged": "^17.2.0",
|
|
85
78
|
"publint": "^0.3.21",
|
|
86
|
-
"semantic-release": "^25.0.8",
|
|
87
79
|
"tsdown": "^0.22.13",
|
|
88
80
|
"turbo": "^2.10.8",
|
|
89
81
|
"typescript": "^6.0.3",
|
|
90
|
-
"typescript-eslint": "^8.65.0",
|
|
91
82
|
"vitest": "^4.1.10"
|
|
92
83
|
},
|
|
93
84
|
"lint-staged": {
|