150 lines
5.4 KiB
TypeScript
150 lines
5.4 KiB
TypeScript
import { strToU8, zipSync, type Zippable } from "fflate";
|
|
|
|
import type { OdfFormat } from "../../../src/office/odf";
|
|
|
|
export const MEDIA_TYPES: Readonly<Record<OdfFormat, string>> = Object.freeze({
|
|
odt: "application/vnd.oasis.opendocument.text",
|
|
ods: "application/vnd.oasis.opendocument.spreadsheet",
|
|
odp: "application/vnd.oasis.opendocument.presentation",
|
|
});
|
|
|
|
export const XMLNS = [
|
|
'xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"',
|
|
'xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"',
|
|
'xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0"',
|
|
'xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0"',
|
|
'xmlns:presentation="urn:oasis:names:tc:opendocument:xmlns:presentation:1.0"',
|
|
'xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0"',
|
|
'xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0"',
|
|
'xmlns:xlink="http://www.w3.org/1999/xlink"',
|
|
'xmlns:dc="http://purl.org/dc/elements/1.1/"',
|
|
'xmlns:meta="urn:oasis:names:tc:opendocument:xmlns:meta:1.0"',
|
|
'xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0"',
|
|
'xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0"',
|
|
].join(" ");
|
|
|
|
interface FixtureOptions {
|
|
extra?: Record<string, Uint8Array>;
|
|
manifest?: string | false;
|
|
meta?: string;
|
|
styles?: string;
|
|
mediaType?: string;
|
|
}
|
|
|
|
export function makeOdf(
|
|
format: OdfFormat,
|
|
content: string,
|
|
options: FixtureOptions = {},
|
|
): ArrayBuffer {
|
|
const mediaType = options.mediaType ?? MEDIA_TYPES[format];
|
|
const extra = options.extra ?? {};
|
|
const manifest =
|
|
options.manifest === false
|
|
? undefined
|
|
: (options.manifest ?? makeManifest(mediaType, Object.keys(extra)));
|
|
const files: Zippable = {
|
|
mimetype: [strToU8(mediaType), { level: 0 }],
|
|
"content.xml": strToU8(content),
|
|
};
|
|
if (manifest) files["META-INF/manifest.xml"] = strToU8(manifest);
|
|
if (options.meta) files["meta.xml"] = strToU8(options.meta);
|
|
if (options.styles) files["styles.xml"] = strToU8(options.styles);
|
|
for (const [path, value] of Object.entries(extra)) files[path] = value;
|
|
return ownedBuffer(zipSync(files, { level: 6 }));
|
|
}
|
|
|
|
export function makeManifest(
|
|
mediaType: string,
|
|
extraPaths: readonly string[] = [],
|
|
): string {
|
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
<manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0">
|
|
<manifest:file-entry manifest:full-path="/" manifest:media-type="${mediaType}"/>
|
|
<manifest:file-entry manifest:full-path="content.xml" manifest:media-type="text/xml"/>
|
|
${extraPaths
|
|
.map(
|
|
(path) =>
|
|
`<manifest:file-entry manifest:full-path="${path}" manifest:media-type="${mediaFor(path)}"/>`,
|
|
)
|
|
.join("")}
|
|
</manifest:manifest>`;
|
|
}
|
|
|
|
export function odtContent(body: string): string {
|
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
<office:document-content ${XMLNS} office:version="1.3">
|
|
<office:automatic-styles>
|
|
<style:style style:name="InlineBold" style:family="text">
|
|
<style:text-properties fo:font-weight="bold"/>
|
|
</style:style>
|
|
</office:automatic-styles>
|
|
<office:body><office:text>${body}</office:text></office:body>
|
|
</office:document-content>`;
|
|
}
|
|
|
|
export function odsContent(body: string): string {
|
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
<office:document-content ${XMLNS} office:version="1.3">
|
|
<office:body><office:spreadsheet>${body}</office:spreadsheet></office:body>
|
|
</office:document-content>`;
|
|
}
|
|
|
|
export function odpContent(body: string): string {
|
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
<office:document-content ${XMLNS} office:version="1.3">
|
|
<office:body><office:presentation>${body}</office:presentation></office:body>
|
|
</office:document-content>`;
|
|
}
|
|
|
|
export function zipWithTraversal(content: string): ArrayBuffer {
|
|
const files: Zippable = {
|
|
mimetype: [strToU8(MEDIA_TYPES.odt), { level: 0 }],
|
|
"content.xml": strToU8(content),
|
|
"../escape.txt": strToU8("no"),
|
|
};
|
|
return ownedBuffer(zipSync(files));
|
|
}
|
|
|
|
export function zipWithEncodedCollision(content: string): ArrayBuffer {
|
|
const files: Zippable = {
|
|
mimetype: [strToU8(MEDIA_TYPES.odt), { level: 0 }],
|
|
"content.xml": strToU8(content),
|
|
"Pictures/a.png": new Uint8Array([1]),
|
|
"Pictures/%61.png": new Uint8Array([2]),
|
|
};
|
|
return ownedBuffer(zipSync(files));
|
|
}
|
|
|
|
export function corruptFirstCentralCrc(input: ArrayBuffer): ArrayBuffer {
|
|
const bytes = new Uint8Array(input.slice(0));
|
|
const view = new DataView(bytes.buffer);
|
|
const end = findSignature(bytes, 0x06054b50);
|
|
const centralOffset = view.getUint32(end + 16, true);
|
|
view.setUint32(
|
|
centralOffset + 16,
|
|
view.getUint32(centralOffset + 16, true) ^ 1,
|
|
true,
|
|
);
|
|
return bytes.buffer;
|
|
}
|
|
|
|
function findSignature(bytes: Uint8Array, signature: number): number {
|
|
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
for (let offset = bytes.byteLength - 4; offset >= 0; offset -= 1) {
|
|
if (view.getUint32(offset, true) === signature) return offset;
|
|
}
|
|
throw new Error("Signature not found in test ZIP");
|
|
}
|
|
|
|
function mediaFor(path: string): string {
|
|
if (path.endsWith(".png")) return "image/png";
|
|
if (path.endsWith(".svg")) return "image/svg+xml";
|
|
return "application/octet-stream";
|
|
}
|
|
|
|
function ownedBuffer(bytes: Uint8Array): ArrayBuffer {
|
|
const copy = new Uint8Array(bytes.byteLength);
|
|
copy.set(bytes);
|
|
return copy.buffer;
|
|
}
|