Skip to content
Open
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
8 changes: 8 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1936,6 +1936,14 @@ export namespace stream {
* Styles can add some performance overhead. Default is false
*/
useStyles: boolean;

/**
* XLS-352: an optional Date stamped as the mtime of EVERY zip entry the writer emits, so
* the output is byte-deterministic across runs (a per-entry `date` otherwise defaults to
* `new Date()`). Applies to both the streaming WorkbookWriter and the buffered
* `xlsx.write`/`writeBuffer` path. When omitted, entries keep the wall clock.
*/
zipEntryDate?: Date;
}

interface ArchiverZipOptions {
Expand Down
44 changes: 31 additions & 13 deletions lib/stream/xlsx/workbook-writer.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ class WorkbookWriter {

this.zipOptions = options.zip;

// XLS-352: an optional Date stamped as the mtime of EVERY zip entry this writer appends.
// `date` is a per-entry archiver option (default `new Date()`); archiver ignores any
// module-level `zip: {date}`, so byte-deterministic output requires forwarding this value to
// each `zip.append` call — which the `_append` choke point below does. Left undefined, entries
// keep the wall clock exactly as before, so callers that do not pass it see no change.
this.zipEntryDate = options.zipEntryDate;

this.media = [];
this.commentRefs = [];

Expand All @@ -65,9 +72,21 @@ class WorkbookWriter {
return this._definedNames;
}

// XLS-352 choke point: every zip entry is appended through here, so a caller-supplied
// `zipEntryDate` (see the constructor) is stamped as the entry's mtime. When it is undefined —
// or the individual call already carries its own `date` — the options pass through untouched and
// archiver's default (the wall clock) is preserved. This is the durable, in-fork replacement for
// the server's `PinnedWorkbookWriter` subclass, which wrapped `zip.append` from outside.
_append(data, options) {
if (this.zipEntryDate !== undefined && (!options || options.date === undefined)) {
return this.zip.append(data, {...options, date: this.zipEntryDate});
}
return this.zip.append(data, options);
}

_openStream(path) {
const stream = new StreamBuf({bufSize: 65536, batch: true});
this.zip.append(stream, {name: path});
this._append(stream, {name: path});
stream.on('finish', () => {
stream.emit('zipped');
});
Expand Down Expand Up @@ -187,14 +206,14 @@ class WorkbookWriter {

addStyles() {
return new Promise(resolve => {
this.zip.append(this.styles.xml, {name: 'xl/styles.xml'});
this._append(this.styles.xml, {name: 'xl/styles.xml'});
resolve();
});
}

addThemes() {
return new Promise(resolve => {
this.zip.append(theme1Xml, {name: 'xl/theme/theme1.xml'});
this._append(theme1Xml, {name: 'xl/theme/theme1.xml'});
resolve();
});
}
Expand All @@ -207,7 +226,7 @@ class WorkbookWriter {
{Id: 'rId2', Type: RelType.CoreProperties, Target: 'docProps/core.xml'},
{Id: 'rId3', Type: RelType.ExtenderProperties, Target: 'docProps/app.xml'},
]);
this.zip.append(xml, {name: '/_rels/.rels'});
this._append(xml, {name: '/_rels/.rels'});
resolve();
});
}
Expand All @@ -222,7 +241,7 @@ class WorkbookWriter {
};
const xform = new ContentTypesXform();
const xml = xform.toXml(model);
this.zip.append(xml, {name: '[Content_Types].xml'});
this._append(xml, {name: '[Content_Types].xml'});
resolve();
});
}
Expand All @@ -236,12 +255,12 @@ class WorkbookWriter {
return this.zip.file(medium.filename, {name: filename});
}
if (medium.buffer) {
return this.zip.append(medium.buffer, {name: filename});
return this._append(medium.buffer, {name: filename});
}
if (medium.base64) {
const dataimg64 = medium.base64;
const content = dataimg64.substring(dataimg64.indexOf(',') + 1);
return this.zip.append(content, {name: filename, base64: true});
return this._append(content, {name: filename, base64: true});
}
}
throw new Error('Unsupported media');
Expand All @@ -256,7 +275,7 @@ class WorkbookWriter {
};
const xform = new AppXform();
const xml = xform.toXml(model);
this.zip.append(xml, {name: 'docProps/app.xml'});
this._append(xml, {name: 'docProps/app.xml'});
resolve();
});
}
Expand All @@ -265,7 +284,7 @@ class WorkbookWriter {
return new Promise(resolve => {
const coreXform = new CoreXform();
const xml = coreXform.toXml(this);
this.zip.append(xml, {name: 'docProps/core.xml'});
this._append(xml, {name: 'docProps/core.xml'});
resolve();
});
}
Expand All @@ -275,7 +294,7 @@ class WorkbookWriter {
return new Promise(resolve => {
const sharedStringsXform = new SharedStringsXform();
const xml = sharedStringsXform.toXml(this.sharedStrings);
this.zip.append(xml, {name: '/xl/sharedStrings.xml'});
this._append(xml, {name: '/xl/sharedStrings.xml'});
resolve();
});
}
Expand Down Expand Up @@ -308,13 +327,12 @@ class WorkbookWriter {
return new Promise(resolve => {
const xform = new RelationshipsXform();
const xml = xform.toXml(relationships);
this.zip.append(xml, {name: '/xl/_rels/workbook.xml.rels'});
this._append(xml, {name: '/xl/_rels/workbook.xml.rels'});
resolve();
});
}

addWorkbook() {
const {zip} = this;
const model = {
worksheets: this._worksheets.filter(Boolean),
definedNames: this._definedNames.model,
Expand All @@ -326,7 +344,7 @@ class WorkbookWriter {
return new Promise(resolve => {
const xform = new WorkbookXform();
xform.prepare(model);
zip.append(xform.toXml(model), {name: '/xl/workbook.xml'});
this._append(xform.toXml(model), {name: '/xl/workbook.xml'});
resolve();
});
}
Expand Down
15 changes: 15 additions & 0 deletions lib/utils/zip-stream.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,22 @@ class ZipWriter extends events.EventEmitter {
}
}

// XLS-352: stamp a caller-supplied `entryDate` as the mtime of EVERY entry before generating,
// so the buffered writer's output is byte-deterministic. Doing it here — the single generate
// choke point — rather than per-append is deliberate: JSZip AUTO-CREATES folder entries (`xl/`,
// `xl/worksheets/`, ...) that never pass through `append`, and each defaults to `new Date()`.
// Pinning at append would leave those folder entries on the wall clock (two runs then differ in
// exactly their headers). Iterating `this.zip.files` here covers the file entries and the
// auto-created folder entries alike. Undefined leaves JSZip's default (the wall clock) untouched.
_pinEntryDates() {
if (this.options.entryDate === undefined) return;
Object.keys(this.zip.files).forEach(name => {
this.zip.files[name].date = this.options.entryDate;
});
}

async finalize() {
this._pinEntryDates();
const content = await this.zip.generateAsync(this.options);
this.stream.end(content);
this.emit('finish');
Expand Down
17 changes: 11 additions & 6 deletions lib/xlsx/xform/book/workbook-xform.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,10 @@ class WorkbookXform extends BaseXform {
});
}

if (sheet.pageSetup && (sheet.pageSetup.printTitlesRow || sheet.pageSetup.printTitlesColumn)) {
if (
sheet.pageSetup &&
(sheet.pageSetup.printTitlesRow || sheet.pageSetup.printTitlesColumn)
) {
const ranges = [];

if (sheet.pageSetup.printTitlesColumn) {
Expand Down Expand Up @@ -104,10 +107,7 @@ class WorkbookXform extends BaseXform {
this.map.calcPr.render(xmlStream, model.calcProperties);

// Render pivot caches (both programmatic and preserved)
const allPivotCaches = [
...(model.pivotTables || []),
...(model.preservedPivotCaches || []),
];
const allPivotCaches = [...(model.pivotTables || []), ...(model.preservedPivotCaches || [])];
this.map.pivotCaches.render(xmlStream, allPivotCaches);

xmlStream.closeNode();
Expand Down Expand Up @@ -199,7 +199,12 @@ class WorkbookXform extends BaseXform {
_.each(model.definedNames, definedName => {
if (definedName.name === '_xlnm.Print_Area') {
worksheet = worksheets[definedName.localSheetId];
if (worksheet) {
// An empty or unparseable Print_Area range decodes to `undefined` — the
// classic "print area cleared in Excel (or a third-party writer) which
// leaves the `_xlnm.Print_Area` defined name behind with no ref" artifact.
// Excel and our own streaming reader both treat that as "no print area";
// skip it here rather than crash in colCache.decodeEx(undefined). (XLS-861)
if (worksheet && definedName.ranges && definedName.ranges[0]) {
if (!worksheet.pageSetup) {
worksheet.pageSetup = {};
}
Expand Down
5 changes: 4 additions & 1 deletion lib/xlsx/xlsx.js
Original file line number Diff line number Diff line change
Expand Up @@ -1104,7 +1104,10 @@ class XLSX {
async write(stream, options) {
options = options || {};
const {model} = this.workbook;
const zip = new ZipStream.ZipWriter(options.zip);
// XLS-352: forward an optional `zipEntryDate` into the zip writer so every buffered entry's mtime
// is stamped with it (the buffered twin of the streaming writer's per-entry pin). Undefined leaves
// JSZip's default (the wall clock) untouched, so callers that do not pass it see no change.
const zip = new ZipStream.ZipWriter({...options.zip, entryDate: options.zipEntryDate});
zip.pipe(stream);

this.prepareModel(model, options);
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@protobi/exceljs",
"version": "4.4.0-protobi.10",
"version": "4.4.0-protobi.11",
"description": "Excel Workbook Manager - Temporary fork with pivot table enhancements and bug fixes pending upstream merge",
"private": false,
"license": "MIT",
Expand Down
138 changes: 138 additions & 0 deletions spec/integration/issues/issue-xls352-zip-entry-mtime-pin.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
const fs = require('fs');
const os = require('os');
const path = require('path');
const JSZip = require('jszip');

const ExcelJS = verquire('exceljs');

// XLS-352 — teach the fork to pin every zip entry's mtime itself, so the server's
// `PinnedWorkbookWriter` subclass (which reached past the published surface to wrap `zip.append`
// from inside the base constructor) can be deleted.
//
// The defect: `date` is a PER-ENTRY zip option (archiver's `data.date` / JSZip's file `date`),
// defaulting to `new Date()` at append time. A module-level `zip: {date}` is a no-op — archiver
// never forwards it to an entry header. So each entry's local-file-header mtime carries the wall
// clock at the DOS format's two-second granularity, and two builds are byte-identical only when
// they happen to land in the same two-second bucket (XLS-350's coin-flip).
//
// The fix adds one option, `zipEntryDate`, threaded to EVERY `zip.append` on BOTH writer paths:
// - streaming: `stream.xlsx.WorkbookWriter` — via the `_append` choke point.
// - buffered: `Workbook.xlsx.writeBuffer/writeFile` — via `ZipWriter.append` (JSZip `.file`).
//
// This is pinned three ways per path, because each alone is a false green:
// 1. Byte-identity across a real DOS tick — the determinism property itself.
// 2. Every entry's parsed mtime equals the pin — proves the date reached the entry headers, not
// just that two runs happened to match.
// 3. POSITIVE CONTROL: WITHOUT the option the output differs from the pinned output AND the
// entries carry the wall clock (a recent year), so the option is demonstrably what pins them.
// Revert the fork fix and arms 1+2 stay green vacuously; this arm is what reddens.

// A fixed pin on an even two-second boundary (DOS granularity) and at midday, so a CI timezone
// offset shifts the hour but never the calendar date the readback asserts.
const PIN = new Date(2000, 5, 15, 12, 0, 0);

const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));

let tmpCounter = 0;
function tmpFile() {
return path.join(os.tmpdir(), `xls352-stream-${process.pid}-${Date.now()}-${tmpCounter++}.xlsx`);
}

// Build a workbook through the STREAMING writer and return its bytes. `extraOptions` carries (or
// omits) `zipEntryDate`. `created`/`modified` are pinned too so docProps/core.xml — entry CONTENT,
// the other half of byte-identity — does not itself carry the wall clock and mask the entry-mtime
// property under test.
async function buildStreaming(extraOptions) {
const file = tmpFile();
const wb = new ExcelJS.stream.xlsx.WorkbookWriter({
stream: fs.createWriteStream(file, {flags: 'w'}),
useStyles: true,
useSharedStrings: false,
...extraOptions,
});
wb.created = PIN;
wb.modified = PIN;
const ws = wb.addWorksheet('S');
ws.addRow(['hello', 42]).commit();
ws.commit();
await wb.commit();
const buffer = fs.readFileSync(file);
fs.unlinkSync(file);
return buffer;
}

// Build the same workbook through the BUFFERED writer (`writeBuffer`) and return its bytes.
async function buildBuffered(extraOptions) {
const wb = new ExcelJS.Workbook();
wb.created = PIN;
wb.modified = PIN;
const ws = wb.addWorksheet('S');
ws.addRow(['hello', 42]);
return wb.xlsx.writeBuffer(extraOptions);
}

// Every entry, INCLUDING the folder entries JSZip auto-creates — those default to `new Date()` and
// are exactly what made the buffered writer non-deterministic until the finalize-time pin.
async function entryDates(buffer) {
const zip = await JSZip.loadAsync(buffer);
return Object.values(zip.files).map(entry => entry.date);
}

describe('github issues: XLS-352 fork pins every zip entry mtime (durable fix for XLS-350)', function() {
// Two full builds plus a real >2s delay per determinism arm.
this.timeout(30000);

describe('streaming writer (stream.xlsx.WorkbookWriter)', () => {
it('two runs separated by more than one DOS tick are byte-identical when zipEntryDate is pinned', async () => {
const first = await buildStreaming({zipEntryDate: PIN});
await sleep(2500);
const second = await buildStreaming({zipEntryDate: PIN});
expect(first.equals(second)).to.equal(true);
});

it('stamps every zip entry mtime with zipEntryDate', async () => {
const dates = await entryDates(await buildStreaming({zipEntryDate: PIN}));
expect(dates.length).to.be.greaterThan(3);
dates.forEach(date => {
expect(date.getFullYear()).to.equal(2000);
expect(date.getMonth()).to.equal(5);
expect(date.getDate()).to.equal(15);
});
});

it('POSITIVE CONTROL: without zipEntryDate the bytes differ and entries carry the wall clock', async () => {
const pinned = await buildStreaming({zipEntryDate: PIN});
const unpinned = await buildStreaming({});
expect(pinned.equals(unpinned)).to.equal(false);
const dates = await entryDates(unpinned);
expect(dates.some(date => date.getFullYear() >= 2020)).to.equal(true);
});
});

describe('buffered writer (Workbook.xlsx.writeBuffer)', () => {
it('two runs separated by more than one DOS tick are byte-identical when zipEntryDate is pinned', async () => {
const first = await buildBuffered({zipEntryDate: PIN});
await sleep(2500);
const second = await buildBuffered({zipEntryDate: PIN});
expect(first.equals(second)).to.equal(true);
});

it('stamps every zip entry mtime with zipEntryDate', async () => {
const dates = await entryDates(await buildBuffered({zipEntryDate: PIN}));
expect(dates.length).to.be.greaterThan(3);
dates.forEach(date => {
expect(date.getFullYear()).to.equal(2000);
expect(date.getMonth()).to.equal(5);
expect(date.getDate()).to.equal(15);
});
});

it('POSITIVE CONTROL: without zipEntryDate the bytes differ and entries carry the wall clock', async () => {
const pinned = await buildBuffered({zipEntryDate: PIN});
const unpinned = await buildBuffered({});
expect(pinned.equals(unpinned)).to.equal(false);
const dates = await entryDates(unpinned);
expect(dates.some(date => date.getFullYear() >= 2020)).to.equal(true);
});
});
});
Loading
Loading