Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 99 additions & 48 deletions packages/doc-codec/README.md

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions packages/doc-codec/src/fib/fib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ export interface Fib {
readonly lcbPlcfBtePapx: number;
readonly fcClx: number;
readonly lcbClx: number;

readonly fcSttbfFfn: number;
readonly lcbSttbfFfn: number;
}

export function parseFib(wordDocument: Uint8Array): Fib {
Expand Down Expand Up @@ -111,6 +114,8 @@ export function parseFib(wordDocument: Uint8Array): Fib {
lcbPlcfBtePapx: fcLcb(FC_LCB_VALUE_INDEX.lcbPlcfBtePapx),
fcClx: fcLcb(FC_LCB_VALUE_INDEX.fcClx),
lcbClx: fcLcb(FC_LCB_VALUE_INDEX.lcbClx),
fcSttbfFfn: fcLcb(FC_LCB_VALUE_INDEX.fcSttbfFfn),
lcbSttbfFfn: fcLcb(FC_LCB_VALUE_INDEX.lcbSttbfFfn),
};
}

Expand Down
3 changes: 3 additions & 0 deletions packages/doc-codec/src/fib/offsets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ export const FC_LCB_VALUE_INDEX = {
lcbPlcfBteChpx: 25,
fcPlcfBtePapx: 26,
lcbPlcfBtePapx: 27,
// FibRgFcLcb97's 16th fc/lcb pair, counted forward from fcStshfOrig at pair 0: fcPlcffndRef, fcPlcffndTxt, fcPlcfandRef, fcPlcfandTxt, fcPlcfSed, fcPlcPad, fcPlcfPhe, fcSttbfGlsy, fcPlcfGlsy, fcPlcfHdd, fcPlcfBteChpx, fcPlcfBtePapx, fcPlcfSea, then fcSttbfFfn -- pair 15, value index 30.
fcSttbfFfn: 30,
lcbSttbfFfn: 31,
fcClx: 66,
lcbClx: 67,
} as const;
Expand Down
94 changes: 94 additions & 0 deletions packages/doc-codec/src/fib/write.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// The inverse of fib.ts's parseFib: builds a real File Information Block for nFib 0x00C1 (Word 97), the oldest and simplest FIB generation [MS-DOC] 2.5.1 defines and the one this package's own test-support/fib.ts already targets. cbRgFcLcb is fixed at the 0x005D that nFib 0x00C1 mandates ([MS-DOC]'s own Fib page table), and cswNew is fixed at 0 (also mandated for 0x00C1, so fibRgCswNew is simply absent) -- neither is a parameter, because choosing a newer nFib would change both without this writer gaining anything from it: every structure this package writes (the piece table, the two bin tables, the style sheet, the font table) is unchanged across nFib generations.
//
// Only the fields this writer's own reader needs to get back to the streams it wrote are populated: the four subdocument-boundary fields this package's reader itself reads (cbMac, ccpText) plus the fc/lcb pairs locating the Clx, the two property bin tables, the style sheet, and (when used) the font table. Every other fc/lcb pair -- SttbfAssoc, Dop, the printer-driver structures, and the ~140 others [MS-DOC] defines -- is left zero, which is exactly the "undefined, MUST be ignored" contract most of them carry (see FibRgFcLcb97's own field table). A small number of those unpopulated fields carry a genuine "MUST NOT be zero" clause of their own (SttbfAssoc's lcb among them) that this writer does not satisfy: the resulting bytes are conformant for every structure this package's own reader consults, not a certification that Microsoft Word or another third-party reader would accept the file's every field. See the README's own scope note.

import { readUint16LE } from "../bytes";
import { DocFormatError } from "../errors";
import {
FC_LCB_VALUE_INDEX,
FIB_BASE_FLAG,
FIB_BASE_SIZE,
FIB_CB_RG_FC_LCB_OFFSET,
FIB_CSLW_OFFSET,
FIB_CSLW_REQUIRED,
FIB_CSW_REQUIRED,
FIB_FC_LCB_BLOB_OFFSET,
FIB_RG_LW_OFFSET,
FIB_W_IDENT,
LW_OFFSET,
} from "./offsets";

/** [MS-DOC]'s own Fib page: nFib 0x00C1 mandates cbRgFcLcb 0x005D and cswNew 0. */
const NFIB_WORD_97 = 0x00c1;
const CB_RG_FC_LCB_WORD_97 = 0x005d;
/** FibBase.nFibBack: "this value SHOULD be 0x00BF" for a document written by an application that does not need older-version compatibility beyond it -- carried verbatim from test-support/fib.ts, which cites the same field. */
const N_FIB_BACK = 0x00bf;
/** FibBase's own flags word at offset 10: fExtChar "MUST be 1" ([MS-DOC] 2.5.2), independent of fComplex/fWhichTblStm/fEncrypted/fObfuscated (see offsets.ts's own FIB_BASE_FLAG). */
const FLAG_F_EXT_CHAR = 0x1000;

export interface FibWriteSpec {
readonly ccpText: number;
readonly cbMac: number;
readonly fcClx: number;
readonly lcbClx: number;
readonly fcPlcfBteChpx: number;
readonly lcbPlcfBteChpx: number;
readonly fcPlcfBtePapx: number;
readonly lcbPlcfBtePapx: number;
readonly fcStshf: number;
readonly lcbStshf: number;
/** 0/0 when the document uses no font table (see write.ts). */
readonly fcSttbfFfn: number;
readonly lcbSttbfFfn: number;
}

export function buildFib(spec: FibWriteSpec): Uint8Array<ArrayBuffer> {
const blobBytes = CB_RG_FC_LCB_WORD_97 * 8;
const total = FIB_FC_LCB_BLOB_OFFSET + blobBytes + 2; // + cswNew, which is 0 and carries no fibRgCswNew after it.
const bytes = new Uint8Array(total);
const view = new DataView(bytes.buffer);

view.setUint16(0, FIB_W_IDENT, true);
view.setUint16(2, NFIB_WORD_97, true);
// fWhichTblStm always selects "1Table" (see write.ts's use of tableStreamName); fComplex, fEncrypted and fObfuscated are never set by a fresh, unencrypted, single-save document this writer produces.
view.setUint16(10, FLAG_F_EXT_CHAR | FIB_BASE_FLAG.fWhichTblStm, true);
view.setUint16(12, N_FIB_BACK, true);

view.setUint16(FIB_BASE_SIZE, FIB_CSW_REQUIRED, true); // csw.
view.setUint16(FIB_CSLW_OFFSET, FIB_CSLW_REQUIRED, true); // cslw.

const lw = (offset: number, value: number): void => {
view.setInt32(FIB_RG_LW_OFFSET + offset, value, true);
};
lw(LW_OFFSET.cbMac, spec.cbMac);
lw(LW_OFFSET.ccpText, spec.ccpText);
// Every other FibRgLw97 field (ccpFtn, ccpHdd, ccpAtn, ccpEdn, ccpTxbx, ccpHdrTxbx) stays 0: this writer produces only a Main Document, so every other subdocument this package's own reader is aware of is genuinely empty rather than merely unpopulated.

view.setUint16(FIB_CB_RG_FC_LCB_OFFSET, CB_RG_FC_LCB_WORD_97, true);
const pair = (index: number, fc: number, lcb: number): void => {
const offset = FIB_FC_LCB_BLOB_OFFSET + index * 4;
view.setUint32(offset, fc, true);
view.setUint32(offset + 4, lcb, true);
};
pair(FC_LCB_VALUE_INDEX.fcStshf, spec.fcStshf, spec.lcbStshf);
pair(
FC_LCB_VALUE_INDEX.fcPlcfBteChpx,
spec.fcPlcfBteChpx,
spec.lcbPlcfBteChpx,
);
pair(
FC_LCB_VALUE_INDEX.fcPlcfBtePapx,
spec.fcPlcfBtePapx,
spec.lcbPlcfBtePapx,
);
pair(FC_LCB_VALUE_INDEX.fcSttbfFfn, spec.fcSttbfFfn, spec.lcbSttbfFfn);
pair(FC_LCB_VALUE_INDEX.fcClx, spec.fcClx, spec.lcbClx);
// cswNew (the 2 bytes at FIB_FC_LCB_BLOB_OFFSET + blobBytes) stays 0, which [MS-DOC] mandates for nFib 0x00C1 and which correctly leaves fibRgCswNew absent.

if (readUint16LE(bytes, 0) !== FIB_W_IDENT) {
throw new DocFormatError(
"buildFib produced a Fib whose own wIdent does not read back as 0xA5EC; this is an internal defect, not an input error",
);
}
return bytes;
}
7 changes: 7 additions & 0 deletions packages/doc-codec/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,19 @@ export * from "./plc";
export * from "./detect";
export * from "./fib/offsets";
export * from "./fib/fib";
export * from "./fib/write";
export * from "./text/piece-table";
export * from "./text/piece-table-write";
export * from "./text/characters";
export * from "./text/special";
export * from "./prop/sprm";
export * from "./prop/fkp";
export * from "./prop/fkp-write";
export * from "./prop/chp";
export * from "./prop/chp-write";
export * from "./prop/pap";
export * from "./prop/pap-write";
export * from "./style/stsh";
export * from "./style/fonts";
export * from "./read";
export * from "./write";
91 changes: 91 additions & 0 deletions packages/doc-codec/src/prop/chp-write.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import type { ContentRun } from "document-schema.js";
import { DocFormatError } from "../errors";

// The inverse of chp.ts's applyCharacterSprms: a ContentRun's direct character formatting to a Chpx grpprl -- the bytes a ChpxFkp entry carries (see prop/fkp-write.ts). Each property this package's reader folds gets exactly the sprm chp.ts itself reads back, so a round trip through readDocContent(writeDocContent(x)) recovers the identical value rather than a lossy approximation through a different (but readable) encoding -- sprmCCv for colour rather than the fixed 17-entry sprmCIco palette, for instance, since sprmCCv carries the colour exactly and sprmCIco would have to snap it to the nearest palette entry.
//
// Opcodes are restated as local constants rather than imported from chp.ts: that module names them for its OWN switch cases, and importing them here would couple this file's exports to chp.ts's private naming rather than to the specification both independently cite.

/** sprmCFBold, sprmCFItalic, sprmCFStrike: a 1-byte ToggleOperand, [MS-DOC] 2.9.336. 0x00 and 0x01 are the only values this writer emits -- 0x80/0x81 (inherit/invert relative to a style) have no meaning here, since this package writes no style sheet for a run's character properties to inherit from. */
const SPRM_C_F_BOLD = 0x0835;
const SPRM_C_F_ITALIC = 0x0836;
const SPRM_C_F_STRIKE = 0x0837;
/** sprmCKul: a 1-byte Kul value. 0x00 is "none"; 0x01 is kulSingle, the only underline style ContentRun.underline can express (a plain boolean, not a style enum). */
const SPRM_C_KUL = 0x2a3e;
const KUL_NONE = 0x00;
const KUL_SINGLE = 0x01;
/** sprmCHps: a 2-byte unsigned half-point size. */
const SPRM_C_HPS = 0x4a43;
/** sprmCCv: a 4-byte COLORREF (r, g, b, fAuto). fAuto 0x00 means "use these components", the only form this writer emits. */
const SPRM_C_CV = 0x6870;
/** sprmCRgFtc0: a 2-byte signed index into the font table, [MS-DOC] 2.6.2 -- see style/fonts.ts. */
const SPRM_C_RG_FTC_0 = 0x4a4f;

const HALF_POINTS_PER_POINT = 2;
const COLOR_COMPONENT_MAX = 255;
/** sprmCHps's own operand range: an unsigned 2-byte half-point value. */
const MAX_HPS = 0xffff;

function pushSprm(
bytes: number[],
opcode: number,
operand: readonly number[],
): void {
bytes.push(opcode & 0xff, (opcode >> 8) & 0xff, ...operand);
}

function toggle(value: boolean): number[] {
return [value ? 0x01 : 0x00];
}

function uint16(value: number): number[] {
return [value & 0xff, (value >> 8) & 0xff];
}

function colorRef(color: { r: number; g: number; b: number }): number[] {
const byte = (component: number): number =>
Math.round(component * COLOR_COMPONENT_MAX);
return [byte(color.r), byte(color.g), byte(color.b), 0x00];
}

// Builds the Chpx grpprl for one run's direct formatting. Returns an empty array for a run with no formatting at all, which the caller (write.ts) treats as "no exception" -- exactly the rgb-zero case parseChpxFkp reads back as undefined.
export function encodeCharacterGrpprl(
run: Pick<
ContentRun,
| "bold"
| "italic"
| "underline"
| "strike"
| "sizePt"
| "color"
| "fontFamily"
>,
fontIndexOf: (name: string) => number,
): number[] {
const bytes: number[] = [];
if (run.bold !== undefined) pushSprm(bytes, SPRM_C_F_BOLD, toggle(run.bold));
if (run.italic !== undefined) {
pushSprm(bytes, SPRM_C_F_ITALIC, toggle(run.italic));
}
if (run.strike !== undefined) {
pushSprm(bytes, SPRM_C_F_STRIKE, toggle(run.strike));
}
if (run.underline !== undefined) {
pushSprm(bytes, SPRM_C_KUL, [run.underline ? KUL_SINGLE : KUL_NONE]);
}
if (run.sizePt !== undefined) {
const halfPoints = Math.round(run.sizePt * HALF_POINTS_PER_POINT);
if (halfPoints < 0 || halfPoints > MAX_HPS) {
throw new DocFormatError(
`run sizePt ${run.sizePt} is ${halfPoints} half-points, outside the 0..${MAX_HPS} range sprmCHps's unsigned 2-byte operand can hold`,
);
}
pushSprm(bytes, SPRM_C_HPS, uint16(halfPoints));
}
if (run.color !== undefined) {
pushSprm(bytes, SPRM_C_CV, colorRef(run.color));
}
if (run.fontFamily !== undefined) {
pushSprm(bytes, SPRM_C_RG_FTC_0, uint16(fontIndexOf(run.fontFamily)));
}
return bytes;
}
13 changes: 12 additions & 1 deletion packages/doc-codec/src/prop/chp.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Color } from "document-schema.js";
import { readUint16LE, readUint8 } from "../bytes";
import { readInt16LE, readUint16LE, readUint8 } from "../bytes";
import { DocFormatError } from "../errors";
import { SGC, type Prl } from "./sprm";

Expand All @@ -21,6 +21,8 @@ const SPRM_C_ISTD = 0x4a30;
const SPRM_C_ICO = 0x2a42;
/** sprmCCv: a COLORREF, the richer colour sprm that supersedes sprmCIco where both appear. */
const SPRM_C_CV = 0x6870;
/** sprmCRgFtc0: a 2-byte signed index into the font table (SttbfFfn) naming the font used "only if the conditions for using [sprmCRgFtc1/sprmCRgFtc2/sprmCFtcBi] do not apply" -- the default (non-East-Asian, non-complex-script) font, which is the only one this package reads or writes. */
const SPRM_C_RG_FTC_0 = 0x4a4f;

/** ToggleOperand, [MS-DOC] 2.9.336. 0x80 and 0x81 are relative to the style's own value rather than absolute. */
const TOGGLE_OFF = 0x00;
Expand All @@ -38,6 +40,7 @@ export interface CharacterProperties {
strike?: boolean;
sizePt?: number;
color?: Color;
fontFamily?: string;
/** The istd of a character style applied by sprmCIstd, carried so a caller can resolve the style's own name. */
istd?: number;
}
Expand Down Expand Up @@ -118,6 +121,8 @@ function colorRefColor(operand: Uint8Array): Color | undefined {
export function applyCharacterSprms(
prls: readonly Prl[],
into: CharacterProperties,
// The font table (SttbfFfn, see ../style/fonts.ts) sprmCRgFtc0's operand indexes into. Threaded through rather than resolved by the caller after the fact, because folding is the one place every character sprm's precedence rule (last Prl wins) is already applied -- resolving fontFamily anywhere else would need this same in-order walk repeated.
fonts?: readonly string[],
): CharacterProperties {
for (const prl of prls) {
if (prl.sprm.sgc !== SGC.character) continue;
Expand Down Expand Up @@ -146,6 +151,12 @@ export function applyCharacterSprms(
case SPRM_C_CV:
into.color = colorRefColor(prl.operand);
break;
case SPRM_C_RG_FTC_0: {
const index = readInt16LE(prl.operand, 0);
const name = fonts?.[index];
if (name !== undefined) into.fontFamily = name;
break;
}
default:
// Every other character sprm is a property this reader does not convert. Left alone rather than recorded: the package's scope is stated once, in its README, not restated as a per-property diagnostic on every run of every document.
break;
Expand Down
Loading