mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-11-09 20:07:43 +08:00
fix(web): preserve Excel previews across OPC paths and XML encodings (#4872)
* fix(web): resolve XLSX part relationships and namespace aliases * fix(web): keep Excel browser scenario in the host test program * fix(excel): preserve XML encodings and relocated workbook parts * fix(excel): exclude installed launchers from dependency patch * fix(excel): restore Strict and case-equivalent workbook previews
This commit is contained in:
@@ -143,6 +143,7 @@ pnpm applies local patches to the following packages at install time, so shipped
|
||||
- `@fortune-sheet/core@1.0.4` — [`patches/@fortune-sheet__core@1.0.4.patch`](patches/@fortune-sheet__core@1.0.4.patch)
|
||||
- `@fortune-sheet/react@1.0.4` — [`patches/@fortune-sheet__react@1.0.4.patch`](patches/@fortune-sheet__react@1.0.4.patch)
|
||||
- `@yao-pkg/pkg@6.21.0` — [`patches/@yao-pkg__pkg@6.21.0.patch`](patches/@yao-pkg__pkg@6.21.0.patch)
|
||||
- `exceljs@4.4.0` — [`patches/exceljs@4.4.0.patch`](patches/exceljs@4.4.0.patch)
|
||||
- `node-pty@1.2.0-beta.15` — [`patches/node-pty@1.2.0-beta.15.patch`](patches/node-pty@1.2.0-beta.15.patch)
|
||||
|
||||
## Official Claude Code platform payloads
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/** Independent-writer XLSX previews through a replayed Session and the shipped browser Worker. */
|
||||
import { writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { chromium, type Browser, type Page } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { excelOpeningCases, excelOpeningFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/excel-opening-fixture.ts'
|
||||
import { excelOpcCases, excelOpcFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/excel-opc-fixture.ts'
|
||||
import { excelXmlCases, excelXmlFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/excel-xml-fixture.ts'
|
||||
import { assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage } from './support.ts'
|
||||
|
||||
const snapshotDirectory = fileURLToPath(new URL('../../../snapshots/web/excel-opc', import.meta.url))
|
||||
const mode = webSnapshotMode()
|
||||
|
||||
describe.skipIf(mode === 'record')('web e2e: independent-writer Excel previews', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let consoleErrors: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({
|
||||
developerTools: false,
|
||||
replayFixture: fileURLToPath(new URL('../../../snapshots/web/lifecycle-chrome/session.v3.jsonl', import.meta.url)),
|
||||
compareReplaySession: false,
|
||||
})
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
consoleErrors = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
try { await browser?.close() } finally { await scaffold?.close() }
|
||||
})
|
||||
|
||||
it('opens comments, Tables and equivalent OPC encodings without hiding cell data', async () => {
|
||||
const settled = scaffold.whenTurnSettled()
|
||||
const input = page.locator('[data-composer-input]').first()
|
||||
await input.fill('Reply with the single word LIGHTHOUSE and stop.')
|
||||
await input.press('Enter')
|
||||
const sessionId = await settled
|
||||
await page.getByText('LIGHTHOUSE', { exact: true }).waitFor()
|
||||
const cwd = scaffold.ctx.agents.get(sessionId)?.session.header.cwd
|
||||
if (cwd === undefined) throw new Error('Excel preview Session has no workspace')
|
||||
for (const name of excelOpcCases) await writeFile(join(cwd, `${name}.xlsx`), await excelOpcFixture(name))
|
||||
for (const name of excelOpeningCases) await writeFile(join(cwd, `${name}.xlsx`), await excelOpeningFixture(name))
|
||||
for (const name of excelXmlCases) await writeFile(join(cwd, `${name}.xlsx`), await excelXmlFixture(name))
|
||||
|
||||
const column = page.locator('[data-rightbar-col]')
|
||||
await page.locator('[data-sidebar-right-expand]').click()
|
||||
await column.locator('[data-sidebar-right-guide-entry="files"]').click()
|
||||
await column.locator('[data-files-state="tree"]').waitFor()
|
||||
await column.locator('[data-files-reload]').click()
|
||||
const files = column.locator('[data-dockkit-tab]').filter({ has: page.getByText('Files', { exact: true }) })
|
||||
const preview = column.locator('[data-textpreview-url]')
|
||||
const excel = preview.locator('[data-excel-preview]')
|
||||
const sheets = excel.locator('.luckysheet-sheets-item-name')
|
||||
const notice = excel.locator('[data-excel-unsupported-notice]')
|
||||
const rows = ['# Independent-writer Excel previews', '']
|
||||
await page.context().grantPermissions(['clipboard-read', 'clipboard-write'], { origin: new URL(page.url()).origin })
|
||||
for (const name of [...excelOpcCases, ...excelOpeningCases, ...excelXmlCases]) {
|
||||
await files.click()
|
||||
await column.locator('[data-files-entry="file"]').getByRole('button', { name: `${name}.xlsx`, exact: true }).click()
|
||||
await expect.poll(async () => (await preview.getAttribute('data-textpreview-url'))?.endsWith(`/${name}.xlsx`)).toBe(true)
|
||||
await sheets.getByText('数据', { exact: true }).waitFor()
|
||||
await excel.locator('.fortune-sheet-overlay').click({ position: { x: 70, y: 30 } })
|
||||
await expect.poll(() => excel.locator('.fortune-fx-input').innerText()).toBe('Item')
|
||||
await page.keyboard.press('ControlOrMeta+C')
|
||||
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toMatch(/^Item\r?\n$/)
|
||||
const rich = excelXmlCases.some(candidate => candidate === name)
|
||||
const opening = excelOpeningCases.some(candidate => candidate === name)
|
||||
expect(await notice.count()).toBe(name === 'combined' || rich || opening ? 1 : 0)
|
||||
if (name === 'combined' || rich || opening) {
|
||||
const features = rich ? 'charts, images, shapes, conditional formatting'
|
||||
: name === 'orphan-drawing' ? 'conditional formatting' : 'charts, conditional formatting'
|
||||
expect(await notice.innerText()).toBe(`This preview does not support ${features} in this workbook. Open it in a system application for the full experience.`)
|
||||
}
|
||||
rows.push(`- ${name}.xlsx: 数据 opened; Item copied`)
|
||||
}
|
||||
expect(await sheets.getByText('隐藏页', { exact: true }).isVisible()).toBe(false)
|
||||
const warning = await notice.innerText()
|
||||
expect(warning).toBe('This preview does not support charts, images, shapes, conditional formatting in this workbook. Open it in a system application for the full experience.')
|
||||
rows.push('- Relocated workbook: hidden sheet omitted', `- Relocated workbook notice: ${warning}`)
|
||||
expect(consoleErrors.pageErrors).toEqual([])
|
||||
expect(consoleErrors.warnings).toEqual([])
|
||||
await compareOrRefreshGolden(join(snapshotDirectory, 'excel.expected.md'), rows.join('\n'), mode)
|
||||
await assertFixtureInventory(snapshotDirectory, ['excel.expected.md'])
|
||||
})
|
||||
})
|
||||
@@ -53,6 +53,7 @@
|
||||
"tests/details-session-lifecycle.e2e.ts",
|
||||
"tests/sessionless-header.e2e.ts",
|
||||
"tests/document-preview.e2e.ts",
|
||||
"tests/excel-opc.e2e.ts",
|
||||
"tests/office-fixture.ts",
|
||||
"tests/sidebar-browser.e2e.ts",
|
||||
"tests/plugin-config.e2e.ts",
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-sidebar-documentpreview/README.md
|
||||
README.md: 554aae41691148602b6fe563d802d0215adf2035
|
||||
README.zh.md: f7503f901311649adf161b8dcad07e73267d83d2
|
||||
README.md: 224957fec45fd4d2849df677b9cada62d01d18cd
|
||||
README.zh.md: 18fe9e94617291d85fd3db7bf1c7bcc347f6ec73
|
||||
|
||||
@@ -84,6 +84,8 @@ The lazy Excel chunk bundles FortuneSheet, ExcelJS for XLSX, SheetJS CE for XLS,
|
||||
|
||||
The read-only formula bar displays formulas and cell text literally. Copying preserves an HTML table with escaped cell contents, including saved formula results. The [FortuneSheet patch rationale](../../../.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md) explains the requirements for retaining these behaviors and worksheet selection when upgrading the dependency.
|
||||
|
||||
The pinned [ExcelJS patch](../../../patches/exceljs@4.4.0.patch) resolves the workbook, styles, shared strings, worksheets, comments, Tables, and VML through package relationships, including absolute and relative targets and ASCII case-equivalent part names, and recognizes SpreadsheetML and VML names by namespace URI. Strict OOXML SpreadsheetML and relationship URIs map to the same supported preview features; this is not full Strict conformance. XML parts accept UTF-8 and either byte order of UTF-16; CDATA contributes literal text. Drawing and conditional-format notices follow relationships regardless of part directories. Unreferenced `xl/drawings/*.xml` parts and their relationship files are also omitted, without adding notices; comment VML remains. Missing referenced parts and ambiguous case-equivalent ZIP entries fail the preview. Comments and Table metadata survive parsing but have no dedicated preview controls. The patch covers the Node sources and `dist/exceljs.js`; its browser entry selects that patched bundle. Dependency upgrades must preserve both entry paths and pass the [independent-writer regressions and fuzz diagnostics](tests/fuzz/README.md).
|
||||
|
||||
<a id="office-preview"></a>
|
||||
## Office preview
|
||||
|
||||
|
||||
@@ -84,6 +84,8 @@ CSV 和 TSV 默认使用表格查看器,也可选择纯文本。两者分别
|
||||
|
||||
只读公式栏按字面显示公式和单元格文本。复制时保留 HTML 表格,并转义单元格内容,包括已保存的公式结果。[FortuneSheet 补丁决策](../../../.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.zh.md)说明升级依赖时保留这些行为与工作表选区的要求。
|
||||
|
||||
固定版本的 [ExcelJS 补丁](../../../patches/exceljs@4.4.0.patch)通过包内关系解析工作簿、样式、共享字符串、工作表、批注、Table 和 VML,支持绝对及相对目标,以及 ASCII 大小写等价的部件名,并按命名空间 URI 识别 SpreadsheetML 和 VML 名称。Strict OOXML 的 SpreadsheetML 和关系 URI 映射到相同的已支持预览功能;这不代表完整支持 Strict 标准。XML 部件支持 UTF-8 及两种字节序的 UTF-16;CDATA 按字面文本读取。绘图和条件格式提示按关系查找内容,不依赖部件目录。未被引用的 `xl/drawings/*.xml` 部件及其关系文件也会被移除,但不会产生提示;批注 VML 保持不变。缺失引用部件或存在大小写等价的歧义 ZIP 条目会使预览失败。解析保留批注和 Table 元数据,但预览器没有专门展示它们的控件。补丁同时覆盖 Node 源码和 `dist/exceljs.js`,浏览器入口选择已修补的 bundle。升级依赖时必须保留两个入口的行为,并通过[独立写入器回归测试和 fuzz 诊断](tests/fuzz/README.zh.md)。
|
||||
|
||||
<a id="office-preview"></a>
|
||||
## Office 预览
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** XLSX preview copies without DrawingML parts; worksheet XML and source bytes remain untouched. */
|
||||
import { strFromU8, unzipSync, zipSync, type Unzipped } from 'fflate'
|
||||
import { unzipSync, zipSync, type Unzipped } from 'fflate'
|
||||
import { XMLParser } from 'fast-xml-parser'
|
||||
import type { ExcelUnsupportedFeature } from './model.ts'
|
||||
|
||||
@@ -8,13 +8,21 @@ export class XlsxPreviewArchive {
|
||||
/** Detected workbook content that the preview does not display. */
|
||||
readonly unsupportedFeatures = new Set<ExcelUnsupportedFeature>()
|
||||
private readonly files: Unzipped
|
||||
private readonly parts = new Map<string, Uint8Array>()
|
||||
|
||||
/**
|
||||
* Read an archive without modifying the borrowed source buffer.
|
||||
* @param bytes - Complete XLSX source bytes.
|
||||
* @throws When ZIP entries have ASCII case-equivalent names.
|
||||
*/
|
||||
constructor(private readonly bytes: Uint8Array<ArrayBuffer>) {
|
||||
this.files = unzipSync(bytes)
|
||||
for (const [path, bytes] of Object.entries(this.files)) {
|
||||
if (path.endsWith('/')) continue
|
||||
const key = partKey(path)
|
||||
if (this.parts.has(key)) throw new Error(`Ambiguous XLSX part: ${path}`)
|
||||
this.parts.set(key, bytes)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -23,18 +31,36 @@ export class XlsxPreviewArchive {
|
||||
*/
|
||||
withoutDrawings(): Uint8Array<ArrayBuffer> {
|
||||
const entries = Object.entries(this.files)
|
||||
const retained = entries.filter(([path, bytes]) => {
|
||||
if (/^xl\/worksheets\/[^/]+\.xml$/.test(path)) {
|
||||
this.inspectContent(bytes)
|
||||
return true
|
||||
}
|
||||
if (/^xl\/drawings\/[^/]+\.xml$/.test(path)) {
|
||||
this.inspectContent(bytes)
|
||||
} else if (!/^xl\/drawings\/_rels\/[^/]+\.xml\.rels$/.test(path)) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
const omitted = new Set<string>()
|
||||
for (const [path, bytes] of entries) {
|
||||
// ExcelJS also discovers unreferenced drawings in its conventional directory.
|
||||
if (/^xl\/drawings\/(?:[^/]+\.xml|_rels\/[^/]+\.xml\.rels)$/i.test(path)) omitted.add(partKey(path))
|
||||
const match = /^(.*\/)?_rels\/([^/]+)\.rels$/i.exec(path)
|
||||
if (match === null) continue
|
||||
const owner = `${match[1] ?? ''}${match[2]}`
|
||||
new XMLParser({
|
||||
removeNSPrefix: true, ignoreAttributes: false, attributeNamePrefix: '', parseAttributeValue: false,
|
||||
updateTag: (tag: string, _path, attributes: Record<string, unknown>) => {
|
||||
const type = typeof attributes.Type === 'string'
|
||||
? attributes.Type.replace(/^http:\/\/purl\.oclc\.org\/ooxml\/officeDocument\/relationships\//, 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/') : undefined
|
||||
if (tag !== 'Relationship' || attributes.TargetMode === 'External') return tag
|
||||
const drawing = type === 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing'
|
||||
if (!drawing && type !== 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet') return tag
|
||||
if (typeof attributes.Target !== 'string') throw new Error('Missing XLSX relationship target')
|
||||
const target = resolvePart(owner, attributes.Target)
|
||||
const content = this.parts.get(partKey(target))
|
||||
if (content === undefined) throw new Error(`Missing XLSX part: ${target}`)
|
||||
this.inspectContent(content)
|
||||
if (drawing) {
|
||||
omitted.add(partKey(target))
|
||||
const split = target.lastIndexOf('/')
|
||||
omitted.add(partKey(`${target.slice(0, split + 1)}_rels/${target.slice(split + 1)}.rels`))
|
||||
}
|
||||
return tag
|
||||
},
|
||||
}).parse(decodeXml(bytes))
|
||||
}
|
||||
const retained = entries.filter(([path]) => !omitted.has(partKey(path)))
|
||||
return retained.length === entries.length ? this.bytes : new Uint8Array(zipSync(Object.fromEntries(retained), { level: 0 }))
|
||||
}
|
||||
|
||||
@@ -52,6 +78,33 @@ export class XlsxPreviewArchive {
|
||||
if (tag === 'sheetData') return false
|
||||
return tag
|
||||
},
|
||||
}).parse(strFromU8(bytes))
|
||||
}).parse(decodeXml(bytes))
|
||||
}
|
||||
}
|
||||
|
||||
/** OPC compares part names using ASCII case equivalence, leaving ZIP names intact. */
|
||||
function partKey(path: string): string {
|
||||
return path.replace(/[A-Z]/g, character => character.toLowerCase())
|
||||
}
|
||||
|
||||
/** Decode the mandatory XML encodings without rewriting the archived bytes. */
|
||||
function decodeXml(bytes: Uint8Array): string {
|
||||
const encoding = (bytes[0] === 0xff && bytes[1] === 0xfe) || (bytes[0] === 0x3c && bytes[1] === 0)
|
||||
? 'utf-16le'
|
||||
: (bytes[0] === 0xfe && bytes[1] === 0xff) || (bytes[0] === 0 && bytes[1] === 0x3c)
|
||||
? 'utf-16be' : 'utf-8'
|
||||
return new TextDecoder(encoding, { fatal: true }).decode(bytes)
|
||||
}
|
||||
|
||||
/** Resolve internal relationship targets without accepting traversal above the package root. */
|
||||
function resolvePart(owner: string, target: string): string {
|
||||
const parts = target.startsWith('/') ? [] : owner.split('/').slice(0, -1)
|
||||
for (const segment of target.split('/')) {
|
||||
if (segment === '' || segment === '.') continue
|
||||
if (segment === '..') {
|
||||
if (parts.length === 0) throw new Error('XLSX relationship escapes package')
|
||||
parts.pop()
|
||||
} else parts.push(segment)
|
||||
}
|
||||
return parts.join('/')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/** Independent-writer XLSX files shared by parser and shipped-browser regressions. */
|
||||
import { readFile } from 'node:fs/promises'
|
||||
|
||||
/** Five isolated failures and one workbook combining their features. */
|
||||
export const excelOpcCases = ['comments', 'table', 'prefix', 'part-name', 'relative', 'combined'] as const
|
||||
|
||||
/**
|
||||
* Read an Openpyxl or XlsxWriter fixture without regenerating it with the parser under test.
|
||||
* @param name - Retained fixture name.
|
||||
* @returns Complete XLSX bytes owned by the caller.
|
||||
*/
|
||||
export async function excelOpcFixture(name: typeof excelOpcCases[number]): Promise<Uint8Array<ArrayBuffer>> {
|
||||
return new Uint8Array(await readFile(new URL(`./fixtures/excel-opc/${name}.xlsx`, import.meta.url)))
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/** OPC part resolution and namespace aliases preserve independent-writer workbook contents. */
|
||||
import { createRequire } from 'node:module'
|
||||
import ExcelJS from 'exceljs'
|
||||
import { strFromU8, strToU8, unzipSync, zipSync } from 'fflate'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Config } from '../src/config.ts'
|
||||
import { convertExcel } from '../src/client/excel/convert.ts'
|
||||
import { XlsxPreviewArchive } from '../src/client/excel/xlsx-archive.ts'
|
||||
import { excelOpcCases, excelOpcFixture } from './excel-opc-fixture.ts'
|
||||
import { excelFixture, excelHtmlFixture } from './excel-fixture.ts'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const browserExcel = require('exceljs/dist/exceljs.js') as typeof ExcelJS
|
||||
const limits = Config({}).excel
|
||||
const relsPath = 'xl/worksheets/_rels/budget.xml.rels'
|
||||
|
||||
describe.each([['Node', ExcelJS], ['browser bundle', browserExcel]] as const)('%s ExcelJS entry', (_name, parser) => {
|
||||
it.each(excelOpcCases)('opens %s without changing source bytes', async (name) => {
|
||||
const source = await excelOpcFixture(name)
|
||||
const before = source.slice()
|
||||
const archive = new XlsxPreviewArchive(source)
|
||||
const input = archive.withoutDrawings()
|
||||
const workbook = new parser.Workbook()
|
||||
await workbook.xlsx.load(input.buffer, { ignoreNodes: ['drawing'] })
|
||||
const sheet = workbook.getWorksheet('数据')!
|
||||
expect(sheet.getCell('A1').value).toBe('Item')
|
||||
expect(sheet.getCell('B2').value).toBe(42)
|
||||
if (name === 'comments' || name === 'combined') {
|
||||
expect(sheet.getCell('B2').note).toBe('生成的批注')
|
||||
}
|
||||
if (name === 'table' || name === 'combined') {
|
||||
expect(sheet.getTable('DataTable')).toMatchObject({ table: { name: 'DataTable', tableRef: 'A1:B3' } })
|
||||
}
|
||||
expect(source).toEqual(before)
|
||||
})
|
||||
|
||||
it('binds comment VML independently of relationship order and ZIP entry order', async () => {
|
||||
const source = await excelOpcFixture('combined')
|
||||
const files = unzipSync(new XlsxPreviewArchive(source).withoutDrawings())
|
||||
const comments = 'xl/comments/comment1.xml'
|
||||
files[comments] = strToU8(strFromU8(files[comments]!).replace('<text><t>', '<text><r><rPr><b/></rPr><t>').replace('</t></text>', '</t></r></text>'))
|
||||
const vml = 'xl/drawings/commentsDrawing1.vml'
|
||||
files[vml] = strToU8(strFromU8(files[vml]!).replace('insetmode="auto"', 'insetmode="custom"'))
|
||||
const xml = strFromU8(files[relsPath]!)
|
||||
const relations = xml.match(/<Relationship\b[^>]*\/>/g)!
|
||||
files[relsPath] = strToU8(xml.replace(relations.join(''), [...relations].reverse().join('')))
|
||||
const workbook = new parser.Workbook()
|
||||
await workbook.xlsx.load(new Uint8Array(zipSync(Object.fromEntries(Object.entries(files).reverse()))).buffer, { ignoreNodes: ['drawing'] })
|
||||
expect(workbook.getWorksheet('数据')!.getCell('B2').note).toMatchObject({
|
||||
texts: [{ text: '生成的批注', font: { bold: true } }], margins: { insetmode: 'custom' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('retains styles, formulas, hidden sheets and notices in the combined workbook', async () => {
|
||||
const source = await excelOpcFixture('combined')
|
||||
const result = await convertExcel(source, 'xlsx', limits)
|
||||
const sheet = result.sheets[0]!
|
||||
expect(result.sheets.map(sheet => [sheet.name, sheet.hide])).toEqual([['数据', 0], ['隐藏页', 1]])
|
||||
expect(sheet.celldata!.find(cell => cell.r === 1 && cell.c === 1)!.v).toMatchObject({ v: 42, bl: 1, fc: '#112233', bg: '#FFFF00' })
|
||||
expect(sheet.celldata!.find(cell => cell.r === 4 && cell.c === 0)!.v?.v).toBe(' <s:pic/> & 中文😀\n tail ')
|
||||
expect(sheet.celldata!.find(cell => cell.r === 5 && cell.c === 0)!.v?.f).toBe('=SUM(B2:B3)')
|
||||
expect(sheet.config).toMatchObject({ merge: { '29_7': { r: 29, c: 7, rs: 1, cs: 3 } }, rowhidden: { 2: 0 }, colhidden: { 1: 0 } })
|
||||
expect(sheet.frozen).toMatchObject({ type: 'rangeBoth', range: { row_focus: 0, column_focus: 0 } })
|
||||
expect(result.unsupportedFeatures).toEqual(['charts', 'conditionalFormatting'])
|
||||
const workbook = new ExcelJS.Workbook()
|
||||
await workbook.xlsx.load(new XlsxPreviewArchive(source).withoutDrawings().buffer, { ignoreNodes: ['drawing'] })
|
||||
expect(workbook.getWorksheet('数据')!.getCell('A2').hyperlink).toBe('https://example.com/?a=1&b=2')
|
||||
})
|
||||
|
||||
it.each([excelFixture, excelHtmlFixture])('preserves cells and attributes under equivalent namespace aliases', async (fixture) => {
|
||||
const source = await fixture()
|
||||
const files = unzipSync(source)
|
||||
for (const [path, bytes] of Object.entries(files)) {
|
||||
if (!/^xl\/worksheets\/[^/]+\.xml$/.test(path)) continue
|
||||
files[path] = strToU8(strFromU8(bytes)
|
||||
.replace('xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"', 'xmlns:s="http://schemas.openxmlformats.org/spreadsheetml/2006/main"')
|
||||
.replace(/<(\/?)([A-Za-z_][\w.-]*)(?=[\s/>])/g, '<$1s:$2')
|
||||
.replaceAll('xmlns:r=', 'xmlns:link=').replaceAll(' r:id=', ' link:id='))
|
||||
}
|
||||
expect(await convertExcel(new Uint8Array(zipSync(files)), 'xlsx', limits)).toEqual(await convertExcel(source, 'xlsx', limits))
|
||||
})
|
||||
|
||||
it.each(['part', 'relationship', 'escape', 'external'] as const)('rejects a missing or invalid worksheet %s instead of dropping the sheet', async (defect) => {
|
||||
const files = unzipSync(await excelFixture())
|
||||
if (defect === 'part') delete files['xl/worksheets/sheet2.xml']
|
||||
else {
|
||||
const path = 'xl/_rels/workbook.xml.rels'
|
||||
const xml = strFromU8(files[path]!)
|
||||
files[path] = strToU8(defect === 'relationship' ? xml.replace(/<Relationship\b[^>]*Target="worksheets\/sheet2.xml"[^>]*\/>/, '')
|
||||
: xml.replace('Target="worksheets/sheet2.xml"', defect === 'escape' ? 'Target="../../sheet2.xml"' : 'Target="https://example.com/sheet2.xml" TargetMode="External"'))
|
||||
}
|
||||
await expect(convertExcel(new Uint8Array(zipSync(files)), 'xlsx', limits)).rejects.toMatchObject({ code: 'invalid' })
|
||||
})
|
||||
|
||||
it.each(['xl/comments/comment1.xml', 'xl/tables/table1.xml', 'xl/drawings/commentsDrawing1.vml'])('rejects missing referenced metadata %s', async (path) => {
|
||||
const files = unzipSync(await excelOpcFixture('combined'))
|
||||
Reflect.deleteProperty(files, path)
|
||||
await expect(convertExcel(new Uint8Array(zipSync(files)), 'xlsx', limits)).rejects.toMatchObject({ code: 'invalid' })
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
/** Opening regressions derived from the independent-writer combined workbook. */
|
||||
import { strFromU8, strToU8, unzipSync, zipSync } from 'fflate'
|
||||
import { excelOpcFixture } from './excel-opc-fixture.ts'
|
||||
|
||||
/** Supported URI aliases, ASCII case-equivalent part names and orphan DrawingML. */
|
||||
export const excelOpeningCases = ['strict', 'case-root', 'case-parts', 'orphan-drawing'] as const
|
||||
|
||||
/**
|
||||
* Change only the package property under test in an independent-writer workbook.
|
||||
* @param name - Opening regression to construct.
|
||||
* @returns Complete XLSX bytes owned by the caller.
|
||||
*/
|
||||
export async function excelOpeningFixture(name: typeof excelOpeningCases[number]): Promise<Uint8Array<ArrayBuffer>> {
|
||||
const files = unzipSync(await excelOpcFixture('combined'))
|
||||
const result: Record<string, Uint8Array> = {}
|
||||
for (const [path, bytes] of Object.entries(files)) {
|
||||
let xml = /\.(xml|rels|vml)$/.test(path) ? strFromU8(bytes) : undefined
|
||||
let target = path
|
||||
if (xml !== undefined) {
|
||||
if (name === 'strict') {
|
||||
xml = xml.replaceAll('http://schemas.openxmlformats.org/spreadsheetml/2006/main', 'http://purl.oclc.org/ooxml/spreadsheetml/main')
|
||||
.replaceAll('http://schemas.openxmlformats.org/officeDocument/2006/relationships', 'http://purl.oclc.org/ooxml/officeDocument/relationships')
|
||||
} else if (name === 'case-root' && path === '_rels/.rels') {
|
||||
xml = xml.replace('Target="xl/workbook.xml"', 'Target="XL/WORKBOOK.XML"')
|
||||
} else if (name === 'case-parts') {
|
||||
if (!path.startsWith('docProps/') && !path.startsWith('xl/theme/')) target = path.toUpperCase()
|
||||
if (path.endsWith('.rels')) xml = xml.replace(/Target="([^"]+)"/g, (attribute, value: string) => value.includes('://') ? attribute : `Target="${value.toLowerCase()}"`)
|
||||
} else if (name === 'orphan-drawing') {
|
||||
if (path.endsWith('.rels')) xml = xml.replace(/<Relationship\b[^>]*Type="[^"]*\/drawing"[^>]*\/>/g, '')
|
||||
if (path.startsWith('xl/worksheets/')) xml = xml.replace(/<(?:\w+:)?drawing\b[^>]*\/>/g, '')
|
||||
}
|
||||
}
|
||||
result[target] = xml === undefined ? bytes : strToU8(xml)
|
||||
}
|
||||
return new Uint8Array(zipSync(result))
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/** URI and part-name aliases and orphan drawings preserve usable workbook previews. */
|
||||
import assert from 'node:assert/strict'
|
||||
import { createRequire } from 'node:module'
|
||||
import ExcelJS from 'exceljs'
|
||||
import { strFromU8, strToU8, unzipSync, zipSync } from 'fflate'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Config } from '../src/config.ts'
|
||||
import { convertExcel } from '../src/client/excel/convert.ts'
|
||||
import { XlsxPreviewArchive } from '../src/client/excel/xlsx-archive.ts'
|
||||
import { excelOpeningCases, excelOpeningFixture } from './excel-opening-fixture.ts'
|
||||
import { excelOpcFixture } from './excel-opc-fixture.ts'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const browserExcel = require('exceljs/dist/exceljs.js') as typeof ExcelJS
|
||||
const limits = Config({}).excel
|
||||
|
||||
describe.each([['Node', ExcelJS], ['browser bundle', browserExcel]] as const)('%s opening regressions', (_name, parser) => {
|
||||
it.each(excelOpeningCases)('opens %s with cells, styles, comments, Tables and hyperlinks', async (name) => {
|
||||
const source = await excelOpeningFixture(name)
|
||||
const original = source.slice()
|
||||
const retained = new XlsxPreviewArchive(source).withoutDrawings()
|
||||
const workbook = new parser.Workbook()
|
||||
await workbook.xlsx.load(retained.buffer, { ignoreNodes: ['drawing'] })
|
||||
const sheet = workbook.getWorksheet('数据')
|
||||
assert.ok(sheet)
|
||||
expect(sheet.getCell('A1').value).toBe('Item')
|
||||
expect(sheet.getCell('B2').value).toBe(42)
|
||||
expect(sheet.getCell('B2').font).toMatchObject({ bold: true, color: { argb: 'FF112233' } })
|
||||
expect(sheet.getCell('B2').note).toBe('生成的批注')
|
||||
expect(sheet.getTable('DataTable')).toMatchObject({ table: { tableRef: 'A1:B3' } })
|
||||
expect(sheet.getCell('A2').hyperlink).toBe('https://example.com/?a=1&b=2')
|
||||
expect(workbook.getWorksheet('隐藏页')?.state).toBe('hidden')
|
||||
const originalParts = unzipSync(source)
|
||||
for (const [path, bytes] of Object.entries(unzipSync(retained))) expect(bytes, path).toEqual(originalParts[path])
|
||||
expect(source).toEqual(original)
|
||||
})
|
||||
|
||||
it.each(['xl/workbook.xml', '_rels/.rels'])('rejects ambiguous ASCII case-equivalent entries for %s', async (path) => {
|
||||
const files = unzipSync(await excelOpcFixture('combined'))
|
||||
const bytes = files[path]
|
||||
assert.ok(bytes)
|
||||
files[path.toUpperCase()] = bytes
|
||||
const source = new Uint8Array(zipSync(files))
|
||||
await expect(new parser.Workbook().xlsx.load(source.buffer)).rejects.toThrow(/Ambiguous.*part/i)
|
||||
})
|
||||
})
|
||||
|
||||
it.each(excelOpeningCases)('preserves the complete preview and referenced-content notices for %s', async (name) => {
|
||||
const expected = await convertExcel(await excelOpcFixture('combined'), 'xlsx', limits)
|
||||
if (name === 'orphan-drawing') expected.unsupportedFeatures = ['conditionalFormatting']
|
||||
expect(await convertExcel(await excelOpeningFixture(name), 'xlsx', limits)).toEqual(expected)
|
||||
})
|
||||
|
||||
it('omits orphan DrawingML and its relationships while retaining comment VML', async () => {
|
||||
const source = await excelOpeningFixture('orphan-drawing')
|
||||
const original = unzipSync(source)
|
||||
expect(original['xl/drawings/drawing1.xml']).toBeDefined()
|
||||
expect(original['xl/drawings/_rels/drawing1.xml.rels']).toBeDefined()
|
||||
const archive = new XlsxPreviewArchive(source)
|
||||
const retained = unzipSync(archive.withoutDrawings())
|
||||
expect(retained['xl/drawings/drawing1.xml']).toBeUndefined()
|
||||
expect(retained['xl/drawings/_rels/drawing1.xml.rels']).toBeUndefined()
|
||||
expect(retained['xl/drawings/commentsDrawing1.vml']).toEqual(original['xl/drawings/commentsDrawing1.vml'])
|
||||
expect([...archive.unsupportedFeatures]).toEqual(['conditionalFormatting'])
|
||||
})
|
||||
|
||||
it('rejects ambiguous part names before filtering can hide either entry', async () => {
|
||||
const files = unzipSync(await excelOpcFixture('combined'))
|
||||
const drawing = files['xl/drawings/drawing1.xml']
|
||||
assert.ok(drawing)
|
||||
files['XL/DRAWINGS/DRAWING1.XML'] = drawing
|
||||
await expect(convertExcel(new Uint8Array(zipSync(files)), 'xlsx', limits)).rejects.toMatchObject({ code: 'invalid' })
|
||||
})
|
||||
|
||||
it('does not fold non-ASCII characters when resolving part names', async () => {
|
||||
const files = unzipSync(await excelOpcFixture('combined'))
|
||||
const path = 'xl/_rels/workbook.xml.rels'
|
||||
const rels = files[path]
|
||||
const sheet = files['xl/worksheets/budget.xml']
|
||||
assert.ok(rels)
|
||||
assert.ok(sheet)
|
||||
files[path] = strToU8(strFromU8(rels).replace('worksheets/budget.xml', 'worksheets/Ä.xml'))
|
||||
files['xl/worksheets/ä.xml'] = sheet
|
||||
await expect(convertExcel(new Uint8Array(zipSync(files)), 'xlsx', limits)).rejects.toMatchObject({ code: 'invalid' })
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
/** Equivalent XML encodings and package layouts produced with XlsxWriter. */
|
||||
import { readFile } from 'node:fs/promises'
|
||||
|
||||
/** Baseline and equivalent workbooks covering XML text and relocated core parts. */
|
||||
export const excelXmlCases = ['rich', 'utf16-sheet', 'utf16-metadata', 'workbook-path', 'styles-path', 'strings-path', 'cdata', 'parts-path'] as const
|
||||
|
||||
/**
|
||||
* Read committed independent-writer bytes for parser and browser tests.
|
||||
* @param name - Retained fixture name.
|
||||
* @returns Complete XLSX bytes owned by the caller.
|
||||
*/
|
||||
export async function excelXmlFixture(name: typeof excelXmlCases[number]): Promise<Uint8Array<ArrayBuffer>> {
|
||||
return new Uint8Array(await readFile(new URL(`./fixtures/excel-xml/${name}.xlsx`, import.meta.url)))
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/** XML decoding and OPC discovery preserve cells, metadata and preview notices. */
|
||||
import { createRequire } from 'node:module'
|
||||
import ExcelJS from 'exceljs'
|
||||
import { strFromU8, strToU8, unzipSync, zipSync } from 'fflate'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Config } from '../src/config.ts'
|
||||
import { convertExcel } from '../src/client/excel/convert.ts'
|
||||
import { XlsxPreviewArchive } from '../src/client/excel/xlsx-archive.ts'
|
||||
import { excelXmlCases, excelXmlFixture } from './excel-xml-fixture.ts'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const browserExcel = require('exceljs/dist/exceljs.js') as typeof ExcelJS
|
||||
const limits = Config({}).excel
|
||||
|
||||
describe.each([['Node', ExcelJS], ['browser bundle', browserExcel]] as const)('%s XML reader', (_name, parser) => {
|
||||
it.each(excelXmlCases)('preserves comments and Tables in %s', async (name) => {
|
||||
const source = await excelXmlFixture(name)
|
||||
const original = source.slice()
|
||||
const archive = new XlsxPreviewArchive(source)
|
||||
const retained = archive.withoutDrawings()
|
||||
const workbook = new parser.Workbook()
|
||||
await workbook.xlsx.load(retained.buffer, { ignoreNodes: ['drawing'] })
|
||||
const sheet = workbook.getWorksheet('数据')!
|
||||
expect(sheet.getCell('A5').value).toBe(' <chart/> & 中文😀\n tail ')
|
||||
expect(sheet.getCell('A6').value).toEqual({ formula: 'SUM(B2:B3)', result: 57 })
|
||||
expect(sheet.getCell('B2').note).toMatchObject({ texts: [{ text: '生成的批注' }] })
|
||||
expect(sheet.getTable('Table1')).toMatchObject({ table: { name: 'Table1', tableRef: 'A1:B3' } })
|
||||
expect(sheet.getCell('B2').font).toMatchObject({ bold: true, color: { argb: 'FF112233' } })
|
||||
expect(workbook.getWorksheet('隐藏页')!.state).toBe('hidden')
|
||||
const originalParts = unzipSync(source)
|
||||
for (const [path, bytes] of Object.entries(unzipSync(retained))) expect(bytes, path).toEqual(originalParts[path])
|
||||
expect(source).toEqual(original)
|
||||
})
|
||||
})
|
||||
|
||||
it.each(excelXmlCases)('preserves the complete preview for %s', async (name) => {
|
||||
const expected = await convertExcel(await excelXmlFixture('rich'), 'xlsx', limits)
|
||||
expect(await convertExcel(await excelXmlFixture(name), 'xlsx', limits)).toEqual(expected)
|
||||
expect(expected.unsupportedFeatures).toEqual(['charts', 'images', 'shapes', 'conditionalFormatting'])
|
||||
const sheet = expected.sheets[0]!
|
||||
expect(sheet.celldata!.find(cell => cell.r === 1 && cell.c === 1)!.v).toMatchObject({ v: 42, bl: 1, fc: '#112233', bg: '#FFFF00' })
|
||||
expect(sheet.config).toMatchObject({ merge: { '29_7': { r: 29, c: 7, rs: 1, cs: 3 } }, rowhidden: { 2: 0 }, colhidden: { 1: 0 } })
|
||||
expect(sheet.frozen).toMatchObject({ type: 'rangeBoth', range: { row_focus: 0, column_focus: 0 } })
|
||||
})
|
||||
|
||||
it.each(['utf-16le', 'utf-16be'] as const)('decodes %s parts and relationships with and without a BOM', async (encoding) => {
|
||||
const source = await excelXmlFixture('rich')
|
||||
const expected = await convertExcel(source, 'xlsx', limits)
|
||||
for (const bom of [false, true]) {
|
||||
const files = unzipSync(source)
|
||||
for (const [path, bytes] of Object.entries(files)) {
|
||||
if (!/\.(xml|rels|vml)$/.test(path)) continue
|
||||
const xml = strFromU8(bytes).replace(/encoding="UTF-8"/i, 'encoding="UTF-16"')
|
||||
const encoded = Buffer.from(`${bom ? '\uFEFF' : ''}${xml}`, 'utf16le')
|
||||
if (encoding === 'utf-16be') encoded.swap16()
|
||||
files[path] = encoded
|
||||
}
|
||||
expect(await convertExcel(new Uint8Array(zipSync(files)), 'xlsx', limits)).toEqual(expected)
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['xl/workbook.xml', 'xl/styles.xml', 'xl/sharedStrings.xml', '_rels/.rels', 'xl/_rels/workbook.xml.rels'])('rejects missing core part %s', async (path) => {
|
||||
const files = unzipSync(await excelXmlFixture('rich'))
|
||||
Reflect.deleteProperty(files, path)
|
||||
await expect(convertExcel(new Uint8Array(zipSync(files)), 'xlsx', limits)).rejects.toMatchObject({ code: 'invalid' })
|
||||
})
|
||||
|
||||
it('ignores unrelated binary parts without trying to decode them as XML', async () => {
|
||||
const source = await excelXmlFixture('rich')
|
||||
const files = unzipSync(source)
|
||||
files['xl/vbaProject.bin'] = Uint8Array.of(0xff, 0x80, 0x81)
|
||||
expect(await convertExcel(new Uint8Array(zipSync(files)), 'xlsx', limits)).toEqual(await convertExcel(source, 'xlsx', limits))
|
||||
})
|
||||
|
||||
it('preserves text split across escaped text and CDATA segments', async () => {
|
||||
const source = await excelXmlFixture('rich')
|
||||
const files = unzipSync(source)
|
||||
const path = 'xl/sharedStrings.xml'
|
||||
files[path] = strToU8(strFromU8(files[path]!).replace('Item</t>', 'I<![CDATA[t]]>em</t>'))
|
||||
expect(await convertExcel(new Uint8Array(zipSync(files)), 'xlsx', limits)).toEqual(await convertExcel(source, 'xlsx', limits))
|
||||
})
|
||||
|
||||
it('finds a drawing owned by a part at the package root', () => {
|
||||
const archive = new XlsxPreviewArchive(new Uint8Array(zipSync({
|
||||
'_rels/root.xml.rels': strToU8('<Relationships><Relationship Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing" Target="drawing.xml"/></Relationships>'),
|
||||
'drawing.xml': strToU8('<wsDr><chart/></wsDr>'),
|
||||
'_rels/drawing.xml.rels': strToU8('<Relationships/>'),
|
||||
})))
|
||||
expect(Object.keys(unzipSync(archive.withoutDrawings()))).toEqual(['_rels/root.xml.rels'])
|
||||
expect([...archive.unsupportedFeatures]).toEqual(['charts'])
|
||||
})
|
||||
|
||||
it('rejects a worksheet relationship with no target', async () => {
|
||||
const files = unzipSync(await excelXmlFixture('rich'))
|
||||
const path = 'xl/_rels/workbook.xml.rels'
|
||||
files[path] = strToU8(strFromU8(files[path]!).replace('Target="worksheets/sheet1.xml"', ''))
|
||||
await expect(convertExcel(new Uint8Array(zipSync(files)), 'xlsx', limits)).rejects.toMatchObject({ code: 'invalid' })
|
||||
})
|
||||
|
||||
it.each(['missing', 'external', 'duplicate'] as const)('rejects a %s officeDocument relationship', async (defect) => {
|
||||
const files = unzipSync(await excelXmlFixture('rich'))
|
||||
const xml = strFromU8(files['_rels/.rels']!)
|
||||
const relation = xml.match(/<Relationship\b[^>]*officeDocument"[^>]*\/>/)![0]
|
||||
files['_rels/.rels'] = strToU8(xml.replace(relation, defect === 'missing' ? '' : defect === 'duplicate' ? relation + relation : relation.replace('/>', ' TargetMode="External"/>')))
|
||||
await expect(convertExcel(new Uint8Array(zipSync(files)), 'xlsx', limits)).rejects.toMatchObject({ code: 'invalid' })
|
||||
})
|
||||
|
||||
it('rejects malformed UTF-16 without replacing invalid code units', async () => {
|
||||
const files = unzipSync(await excelXmlFixture('rich'))
|
||||
files['xl/worksheets/sheet1.xml'] = Uint8Array.of(0xff, 0xfe, 0x3c)
|
||||
await expect(convertExcel(new Uint8Array(zipSync(files)), 'xlsx', limits)).rejects.toMatchObject({ code: 'invalid' })
|
||||
})
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-sidebar-documentpreview/tests/fuzz/README.md
|
||||
README.md: 7a95b5cedb7dbcbfbc1f0773513ff6488440fd98
|
||||
README.zh.md: 608acf3d1eb83028e3a916dff3e87cc0e88b2ee6
|
||||
@@ -0,0 +1,46 @@
|
||||
# XLSX fuzz diagnostics
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Generate spreadsheet files with Openpyxl and XlsxWriter, then compare the source XLSX adapter against independently recorded cell values, formulas, and unsupported-content categories. This explicit diagnostic campaign retains failures and exits nonzero; it is not part of the default unit suite.
|
||||
|
||||
## Run a campaign
|
||||
|
||||
Install workspace dependencies and use a Python interpreter with Openpyxl, XlsxWriter, and Pillow. In PowerShell, set `$excelFuzzPython` to that interpreter's executable path. Run from the repository root with a new output directory:
|
||||
|
||||
```powershell
|
||||
& $excelFuzzPython packages/client/ui-sidebar-documentpreview/tests/fuzz/generate.py .artifacts/excel-fuzz-new --seed 4863 --random-cases 400
|
||||
pnpm exec tsx packages/client/ui-sidebar-documentpreview/tests/fuzz/run.client.ts .artifacts/excel-fuzz-new
|
||||
```
|
||||
|
||||
The seed controls feature combinations, values, and byte mutations. ZIP metadata is fixed; reproducibility also requires the writer versions recorded in `manifest.json`. The generator validates internal relationship targets and reloads oracle cells with Openpyxl before classifying a workbook as valid. Rejected candidates remain in `generatorErrors`; random byte mutations are classified separately.
|
||||
|
||||
Replay selected cases from an existing corpus:
|
||||
|
||||
```sh
|
||||
pnpm exec tsx packages/client/ui-sidebar-documentpreview/tests/fuzz/run.client.ts .artifacts/excel-fuzz-new --only openpyxl-table --report table.json
|
||||
```
|
||||
|
||||
The runner gives each input a disposable-worker deadline of 10 seconds and checks input immutability, cell values, formula caches, and notice ordering/uniqueness. `--timeout-ms` changes that diagnostic deadline. Reports retain every outcome; `repros/` contains representative failures with fewer feature combinations, not a claim of globally minimal inputs. Accepted damaged ZIPs are informational and need separate inspection.
|
||||
|
||||
## Compare an older adapter
|
||||
|
||||
`--baseline-ref <commit-or-ref> --report baseline.json` reads that revision's `xlsx.ts` from Git and disables notice comparisons. It still uses the current dependency versions and shared conversion helpers; it cannot reproduce an unpatched ExcelJS baseline after installing the patch. Retain reports from the original dependency state or run that revision in a separate checkout. The temporary baseline module is removed after its worker exits. This compares source parsing, not FortuneSheet browser rendering or the shipped Web profile.
|
||||
|
||||
## Retained regressions
|
||||
|
||||
The [OPC fixtures](../fixtures/excel-opc/) contain five isolated failures copied from the seed 4863 corpus and a combined Openpyxl workbook. Openpyxl 3.1.5 produces comments, Tables, namespace-prefix and part-name cases; XlsxWriter 3.2.9 produces the relative-target case. The combined workbook adds chart, conditional formatting, frozen headings, hidden rows/columns/sheet, a merge, styles, a hyperlink, literal XML-looking text, and a formula, then applies `sheet-prefix` and `sheet-filename` mutations. Openpyxl independently validates the saved inputs. Unit and browser tests read these committed bytes without Python.
|
||||
|
||||
The [XML fixtures](../fixtures/excel-xml/) retain one XlsxWriter 3.2.9 workbook and seven equivalent encodings: UTF-16 worksheet or metadata XML, relocated workbook/styles/shared strings, CDATA text, and relocated worksheet/drawing directories. Each retains a formula cache, Unicode and XML-looking text, comment, Table, styles, merge, frozen headings, hidden content, and all four notice categories. When upgrading ExcelJS, compare the complete preview and comment/Table metadata through both Node and browser entries, then run `apps/web/tests/excel-opc.e2e.ts` against the built Worker. Keep original corpus reports; save replay results under a new `--report` name and inspect ZIP directory integrity separately from independent-reader acceptance.
|
||||
|
||||
The [opening fixtures](../excel-opening-fixture.ts) derive Strict URI aliases, root-target and part-name case variants, and orphan DrawingML from the combined Openpyxl file. These transformations retain cell data and run through both parser entries, full preview comparison, and the shipped browser Worker. Ambiguous ASCII case-equivalent entries are rejected; non-ASCII names remain distinct.
|
||||
|
||||
Run the Node and browser-bundle parser regressions:
|
||||
|
||||
```sh
|
||||
pnpm exec vitest run packages/client/ui-sidebar-documentpreview/tests/excel-opening.client.spec.ts packages/client/ui-sidebar-documentpreview/tests/excel-opc.client.spec.ts packages/client/ui-sidebar-documentpreview/tests/excel-xml.client.spec.ts
|
||||
```
|
||||
|
||||
Fuzz reports record each input's SHA-256, whether it opened, and the worktree status. `validOpened` and `validRejected` separate opening failures from value differences; strict exit status still rejects either defect. Known 1904 time/duration offsets and empty-string formula-cache differences remain diagnostic failures. Corrupt inputs accepted by the parser remain informational.
|
||||
|
||||
Maintain the ExcelJS patch with `pnpm patch` and `pnpm patch-commit` against the published package. Installed dependency trees contain generated launchers that must not enter the patch. Validate patch application in a fresh `pnpm patch --edit-dir <new-directory>` extraction before relying on tests against a cached installation.
|
||||
@@ -0,0 +1,46 @@
|
||||
# XLSX 模糊测试诊断
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
使用 Openpyxl 和 XlsxWriter 生成表格文件,再将源码 XLSX 适配器的结果与独立记录的单元格值、公式和不支持内容类别比较。此诊断任务需要显式运行,会保留失败结果并以非零状态退出;它不属于默认单元测试集。
|
||||
|
||||
## 运行测试
|
||||
|
||||
安装工作区依赖,并准备装有 Openpyxl、XlsxWriter 和 Pillow 的 Python 解释器。在 PowerShell 中,将 `$excelFuzzPython` 设置为该解释器的可执行文件路径。从仓库根目录运行,并使用新的输出目录:
|
||||
|
||||
```powershell
|
||||
& $excelFuzzPython packages/client/ui-sidebar-documentpreview/tests/fuzz/generate.py .artifacts/excel-fuzz-new --seed 4863 --random-cases 400
|
||||
pnpm exec tsx packages/client/ui-sidebar-documentpreview/tests/fuzz/run.client.ts .artifacts/excel-fuzz-new
|
||||
```
|
||||
|
||||
种子控制功能组合、数值和字节变异。ZIP 元数据保持固定;复现还依赖 `manifest.json` 记录的写入器版本。生成器先验证内部关系目标,并用 Openpyxl 重新读取预期单元格,再将工作簿归类为合法文件。被排除的候选保留在 `generatorErrors` 中;随机字节变异单独分类。
|
||||
|
||||
从已有样本集重跑选定用例:
|
||||
|
||||
```sh
|
||||
pnpm exec tsx packages/client/ui-sidebar-documentpreview/tests/fuzz/run.client.ts .artifacts/excel-fuzz-new --only openpyxl-table --report table.json
|
||||
```
|
||||
|
||||
执行器为每个输入设置 10 秒的可终止 Worker 截止时间,并检查输入不变性、单元格值、公式缓存,以及提示顺序和唯一性。`--timeout-ms` 可修改该诊断时限。报告保留全部结果;`repros/` 包含功能组合较少的代表性失败文件,不声称输入已达到全局最小。被接受的损坏 ZIP 仅作为观察结果,需要单独检查。
|
||||
|
||||
## 对比旧适配器
|
||||
|
||||
`--baseline-ref <commit-or-ref> --report baseline.json` 从 Git 读取该版本的 `xlsx.ts`,并禁用提示比较。它仍使用当前依赖版本和共用转换辅助函数,安装补丁后无法复现未修补 ExcelJS 的基线。须保留原依赖状态下的报告,或在独立检出目录中运行旧版本。Worker 退出后会删除临时基线模块。此对比检查源码解析,不覆盖 FortuneSheet 浏览器渲染或发布的 Web 配置。
|
||||
|
||||
## 保留的回归用例
|
||||
|
||||
[OPC fixture](../fixtures/excel-opc/) 包含从 seed 4863 语料复制的五类独立失败文件,以及一个组合 Openpyxl 工作簿。Openpyxl 3.1.5 生成批注、Table、命名空间前缀和部件名用例,XlsxWriter 3.2.9 生成相对目标用例。组合工作簿加入图表、条件格式、冻结表头、隐藏行/列/工作表、合并、样式、超链接、类似 XML 的字面文本和公式,再应用 `sheet-prefix` 与 `sheet-filename` 变异。Openpyxl 独立校验保存的输入。单元测试和浏览器测试读取已提交的字节,无需 Python。
|
||||
|
||||
[XML fixture](../fixtures/excel-xml/) 保留一个 XlsxWriter 3.2.9 工作簿及七种等价编码:UTF-16 工作表或元数据 XML、移动工作簿/样式/共享字符串部件、CDATA 文本,以及移动工作表/绘图目录。每个文件都保留公式缓存、Unicode 和类似 XML 的文本、批注、Table、样式、合并、冻结标题、隐藏内容和全部四类提示。升级 ExcelJS 时,应通过 Node 与浏览器入口比较完整预览及批注/Table 元数据,再对构建后的 Worker 运行 `apps/web/tests/excel-opc.e2e.ts`。保留原始语料报告;重放时使用新的 `--report` 文件名,并在独立读取器验证之外单独检查 ZIP 目录完整性。
|
||||
|
||||
[打开回归样例](../excel-opening-fixture.ts)基于 Openpyxl 组合文件派生 Strict URI 别名、根目标与部件名大小写变体,以及孤立 DrawingML。变换保留单元格数据,并通过两个解析入口、完整预览比较和实际发布的浏览器 Worker 验证。ASCII 大小写等价的歧义条目会被拒绝;非 ASCII 名称保持区分。
|
||||
|
||||
运行 Node 和浏览器 bundle 解析器回归测试:
|
||||
|
||||
```sh
|
||||
pnpm exec vitest run packages/client/ui-sidebar-documentpreview/tests/excel-opening.client.spec.ts packages/client/ui-sidebar-documentpreview/tests/excel-opc.client.spec.ts packages/client/ui-sidebar-documentpreview/tests/excel-xml.client.spec.ts
|
||||
```
|
||||
|
||||
fuzz 报告记录每个输入的 SHA-256、能否打开以及工作区状态。`validOpened` 和 `validRejected` 将打开失败与值差异分开,严格退出状态仍会拒绝这两类缺陷。已知的 1904 时间/累计时长偏移和空字符串公式缓存差异仍作为诊断失败。解析器接受的损坏输入继续作为观察结果单列。
|
||||
|
||||
通过 `pnpm patch` 和 `pnpm patch-commit` 对发布包维护 ExcelJS 补丁。已安装的依赖树包含生成的启动脚本,不得混入补丁。在依赖缓存安装上的测试结果之前,先用 `pnpm patch --edit-dir <new-directory>` 全新解包,验证补丁能够应用。
|
||||
@@ -0,0 +1,29 @@
|
||||
/** Test-only Client parser isolation; the parent owns deadlines and termination. */
|
||||
import { parentPort, workerData } from 'node:worker_threads'
|
||||
import { convertExcel } from '../../src/client/excel/convert.ts'
|
||||
import type { ExcelLimits, ExcelPreview } from '../../src/client/excel/model.ts'
|
||||
|
||||
if (parentPort === null) throw new Error('The Excel fuzz worker requires a parent port')
|
||||
const port = parentPort
|
||||
const options = workerData as { source?: string }
|
||||
const baseline = options.source === undefined ? undefined : await import(options.source) as {
|
||||
convertXlsx: (bytes: Uint8Array<ArrayBuffer>, limits: ExcelLimits) => Promise<ExcelPreview>
|
||||
}
|
||||
port.on('message', (bytes: Uint8Array<ArrayBuffer>) => { void parseWorkbook(bytes) })
|
||||
|
||||
async function parseWorkbook(bytes: Uint8Array<ArrayBuffer>): Promise<void> {
|
||||
const before = bytes.slice()
|
||||
try {
|
||||
const limits = { maxBytes: 20_000_000, maxCells: 1_000_000, timeoutMs: 10_000 }
|
||||
const preview = baseline === undefined ? await convertExcel(bytes, 'xlsx', limits) : await baseline.convertXlsx(bytes, limits)
|
||||
port.postMessage({ ok: true, preview, inputUnchanged: before.every((value, index) => value === bytes[index]) })
|
||||
} catch (error) {
|
||||
const causes: string[] = []
|
||||
let current: unknown = error
|
||||
while (current instanceof Error && causes.length < 8) {
|
||||
causes.push(`${current.name}: ${current.message}`)
|
||||
current = current.cause
|
||||
}
|
||||
port.postMessage({ ok: false, causes, inputUnchanged: before.every((value, index) => value === bytes[index]) })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
"""Generate replayable XLSX cases using independent writers and equivalent ZIP/XML encodings."""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import copy
|
||||
from datetime import datetime, time, timedelta
|
||||
import io
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import posixpath
|
||||
import random
|
||||
import re
|
||||
import warnings
|
||||
import zipfile
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
import openpyxl
|
||||
from openpyxl.chart import BarChart, Reference
|
||||
from openpyxl.comments import Comment
|
||||
from openpyxl.drawing.image import Image
|
||||
from openpyxl.formatting.rule import ColorScaleRule, DataBarRule, IconSetRule, FormulaRule
|
||||
from openpyxl.styles import Font, PatternFill
|
||||
from openpyxl.utils.datetime import CALENDAR_MAC_1904, to_excel
|
||||
from openpyxl.worksheet.datavalidation import DataValidation
|
||||
from openpyxl.worksheet.table import Table, TableStyleInfo
|
||||
import xlsxwriter
|
||||
|
||||
FEATURES = ['chart', 'image', 'comment', 'conditional', 'conditional-databar', 'conditional-iconset', 'conditional-expression', 'table', 'validation', 'hyperlink',
|
||||
'merge', 'freeze', 'hidden', 'header', 'epoch1904', 'styled']
|
||||
ERRORS = ['#NULL!', '#DIV/0!', '#VALUE!', '#REF!', '#NAME?', '#NUM!', '#N/A',
|
||||
'#SPILL!', '#CALC!', '#GETTING_DATA']
|
||||
PNG = base64.b64decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=')
|
||||
MAIN_NS = 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'
|
||||
|
||||
|
||||
def fixed_zip(files, compression=zipfile.ZIP_DEFLATED, reverse=False, comment=b''):
|
||||
"""Fix ZIP metadata so the same seed yields byte-identical files."""
|
||||
output = io.BytesIO()
|
||||
with zipfile.ZipFile(output, 'w', compression=compression) as archive:
|
||||
for name in sorted(files, reverse=reverse):
|
||||
entry = zipfile.ZipInfo(name, date_time=(2020, 1, 1, 0, 0, 0))
|
||||
entry.compress_type = compression
|
||||
content = files[name]
|
||||
if name == 'docProps/core.xml':
|
||||
# Openpyxl overwrites the modified time during save_workbook.
|
||||
content = re.sub(rb'(<dcterms:(?:created|modified)\b[^>]*>)[^<]*(</dcterms:(?:created|modified)>)', rb'\g<1>2020-01-01T00:00:00Z\g<2>', content)
|
||||
archive.writestr(entry, content)
|
||||
archive.comment = comment
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def unpack(data):
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as archive:
|
||||
return {name: archive.read(name) for name in archive.namelist()}
|
||||
|
||||
|
||||
def validate(data, cells):
|
||||
"""Check internal relationship targets and independently reload all oracle cells."""
|
||||
files = unpack(data)
|
||||
for name, content in files.items():
|
||||
if name.endswith('.rels'):
|
||||
folder = '' if name == '_rels/.rels' else name.split('/_rels/')[0]
|
||||
for rel in ET.fromstring(content):
|
||||
if rel.get('TargetMode') == 'External':
|
||||
continue
|
||||
target = rel.attrib['Target']
|
||||
target = posixpath.normpath(target.lstrip('/') if target.startswith('/') else posixpath.join(folder, target))
|
||||
if target not in files:
|
||||
raise ValueError(f'Missing relationship target {name}: {target}')
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter('ignore', UserWarning)
|
||||
workbook = openpyxl.load_workbook(io.BytesIO(data), data_only=False)
|
||||
for cell in cells:
|
||||
actual = workbook[cell['sheet']].cell(cell['r'] + 1, cell['c'] + 1).value
|
||||
expected = cell.get('formula', cell.get('source', cell.get('value')))
|
||||
if isinstance(expected, dict):
|
||||
continue
|
||||
equivalent_number = type(actual) in (int, float) and type(expected) in (int, float) and math.isclose(actual, expected, rel_tol=1e-12, abs_tol=1e-12)
|
||||
if actual != expected and not equivalent_number:
|
||||
raise ValueError(f'Writer changed {cell}: {actual!r}')
|
||||
workbook.close()
|
||||
|
||||
|
||||
def workbook_bytes(writer, features, values, case_seed):
|
||||
"""Keep expected values independent of the preview implementation."""
|
||||
output = io.BytesIO()
|
||||
cells = []
|
||||
active = set(features)
|
||||
if writer == 'openpyxl':
|
||||
book = openpyxl.Workbook()
|
||||
book.properties.created = book.properties.modified = datetime(2020, 1, 1)
|
||||
if 'epoch1904' in active:
|
||||
book.epoch = CALENDAR_MAC_1904
|
||||
sheet = book.active
|
||||
sheet.title = '数据'
|
||||
else:
|
||||
book = xlsxwriter.Workbook(output, {'in_memory': True, 'date_1904': 'epoch1904' in active})
|
||||
book.set_properties({'created': datetime(2020, 1, 1)})
|
||||
sheet = book.add_worksheet('数据')
|
||||
|
||||
for r, c, value, kind in values:
|
||||
oracle = {'sheet': '数据', 'r': r, 'c': c}
|
||||
if kind == 'formula':
|
||||
formula, cached = value
|
||||
oracle['formula'] = formula
|
||||
oracle['value'] = None if writer == 'openpyxl' else cached
|
||||
if writer == 'openpyxl':
|
||||
sheet.cell(r + 1, c + 1, formula)
|
||||
else:
|
||||
sheet.write_formula(r, c, formula, None, cached)
|
||||
elif kind == 'date':
|
||||
oracle['source'] = {'iso': value.isoformat()}
|
||||
oracle['value'] = to_excel(value)
|
||||
if writer == 'openpyxl':
|
||||
sheet.cell(r + 1, c + 1, value).number_format = 'yyyy-mm-dd hh:mm:ss'
|
||||
else:
|
||||
sheet.write_datetime(r, c, value, book.add_format({'num_format': 'yyyy-mm-dd hh:mm:ss'}))
|
||||
elif kind == 'time':
|
||||
oracle['source'] = {'iso': str(value)}
|
||||
oracle['value'] = to_excel(value)
|
||||
if writer == 'openpyxl':
|
||||
sheet.cell(r + 1, c + 1, value).number_format = '[h]:mm:ss' if isinstance(value, timedelta) else 'hh:mm:ss'
|
||||
else:
|
||||
sheet.write_datetime(r, c, value, book.add_format({'num_format': '[h]:mm:ss'}))
|
||||
elif kind == 'error':
|
||||
oracle['value'] = value
|
||||
if writer == 'openpyxl':
|
||||
cell = sheet.cell(r + 1, c + 1, value)
|
||||
cell.data_type = 'e'
|
||||
else:
|
||||
# XlsxWriter exposes Excel error caches through its formula writer.
|
||||
sheet.write_formula(r, c, '=1/0', None, value)
|
||||
oracle['formula'] = '=1/0'
|
||||
else:
|
||||
oracle['value'] = value
|
||||
if writer == 'openpyxl':
|
||||
sheet.cell(r + 1, c + 1, value)
|
||||
elif isinstance(value, str):
|
||||
sheet.write_string(r, c, value)
|
||||
else:
|
||||
sheet.write(r, c, value)
|
||||
cells.append(oracle)
|
||||
|
||||
if writer == 'openpyxl':
|
||||
if 'chart' in active:
|
||||
chart = BarChart()
|
||||
chart.add_data(Reference(sheet, min_col=2, min_row=1, max_row=3), titles_from_data=True)
|
||||
sheet.add_chart(chart, 'H2')
|
||||
if 'image' in active:
|
||||
sheet.add_image(Image(io.BytesIO(PNG)), 'H18')
|
||||
if 'comment' in active:
|
||||
sheet['B2'].comment = Comment('生成的批注', 'fuzz')
|
||||
if 'conditional' in active:
|
||||
sheet.conditional_formatting.add('B2:B3', ColorScaleRule(start_type='min', start_color='FF0000', end_type='max', end_color='00FF00'))
|
||||
if 'conditional-databar' in active:
|
||||
sheet.conditional_formatting.add('B2:B3', DataBarRule(start_type='min', end_type='max', color='FF336699'))
|
||||
if 'conditional-iconset' in active:
|
||||
sheet.conditional_formatting.add('B2:B3', IconSetRule('3TrafficLights1', 'percent', [0, 33, 67]))
|
||||
if 'conditional-expression' in active:
|
||||
sheet.conditional_formatting.add('B2:B3', FormulaRule(formula=['B2>20'], font=Font(bold=True)))
|
||||
if 'table' in active:
|
||||
table = Table(displayName='DataTable', ref='A1:B3')
|
||||
table.tableStyleInfo = TableStyleInfo(name='TableStyleMedium2', showRowStripes=True)
|
||||
sheet.add_table(table)
|
||||
if 'validation' in active:
|
||||
validation = DataValidation(type='list', formula1='"A,B,C"')
|
||||
sheet.add_data_validation(validation)
|
||||
validation.add('F1:F3')
|
||||
if 'hyperlink' in active:
|
||||
sheet['A2'].hyperlink = 'https://example.com/?a=1&b=2'
|
||||
if 'merge' in active:
|
||||
sheet.merge_cells('H30:J30')
|
||||
if 'freeze' in active:
|
||||
sheet.freeze_panes = 'B2'
|
||||
if 'hidden' in active:
|
||||
sheet.row_dimensions[3].hidden = True
|
||||
sheet.column_dimensions['B'].hidden = True
|
||||
book.create_sheet('隐藏页').sheet_state = 'hidden'
|
||||
if 'header' in active:
|
||||
sheet.oddHeader.center.text = 'Fuzz &P'
|
||||
if 'styled' in active:
|
||||
sheet['B2'].font = Font(bold=True, color='FF112233')
|
||||
sheet['B2'].fill = PatternFill('solid', fgColor='FFFFFF00')
|
||||
book.save(output)
|
||||
else:
|
||||
if 'chart' in active:
|
||||
chart = book.add_chart({'type': 'column'})
|
||||
chart.add_series({'values': ['数据', 1, 1, 2, 1]})
|
||||
sheet.insert_chart('H2', chart)
|
||||
if 'image' in active:
|
||||
sheet.insert_image('H18', 'pixel.png', {'image_data': io.BytesIO(PNG)})
|
||||
if 'comment' in active:
|
||||
sheet.write_comment('B2', '生成的批注')
|
||||
if 'conditional' in active:
|
||||
sheet.conditional_format('B2:B3', {'type': '2_color_scale'})
|
||||
if 'conditional-databar' in active:
|
||||
sheet.conditional_format('B2:B3', {'type': 'data_bar', 'data_bar_2010': True})
|
||||
if 'conditional-iconset' in active:
|
||||
sheet.conditional_format('B2:B3', {'type': 'icon_set', 'icon_style': '3_traffic_lights'})
|
||||
if 'conditional-expression' in active:
|
||||
sheet.conditional_format('B2:B3', {'type': 'formula', 'criteria': '=B2>20', 'format': book.add_format({'bold': True})})
|
||||
if 'textbox' in active:
|
||||
sheet.insert_textbox('H32', 'Text box <chart/>')
|
||||
if 'table' in active:
|
||||
sheet.add_table('A1:B3', {'columns': [{'header': 'Item'}, {'header': 'Amount'}]})
|
||||
if 'validation' in active:
|
||||
sheet.data_validation('F1:F3', {'validate': 'list', 'source': ['A', 'B', 'C']})
|
||||
if 'hyperlink' in active:
|
||||
sheet.write_url('A2', 'https://example.com/?a=1&b=2', string='A')
|
||||
if 'merge' in active:
|
||||
sheet.merge_range('H30:J30', '', book.add_format())
|
||||
if 'freeze' in active:
|
||||
sheet.freeze_panes(1, 1)
|
||||
if 'hidden' in active:
|
||||
sheet.set_row(2, None, None, {'hidden': True})
|
||||
sheet.set_column('B:B', None, None, {'hidden': True})
|
||||
book.add_worksheet('隐藏页').hide()
|
||||
if 'header' in active:
|
||||
sheet.set_header('&CFuzz &P')
|
||||
if 'styled' in active:
|
||||
sheet.write('B2', 42, book.add_format({'bold': True, 'font_color': '#112233', 'bg_color': '#FFFF00'}))
|
||||
book.close()
|
||||
features = [key for feature, key in [('chart', 'charts'), ('image', 'images'), ('textbox', 'shapes')] if feature in active]
|
||||
if any(feature.startswith('conditional') for feature in active):
|
||||
features.append('conditionalFormatting')
|
||||
return fixed_zip(unpack(output.getvalue())), cells, features
|
||||
|
||||
|
||||
def mutate(data, mutation):
|
||||
"""Change encodings or OPC names without changing worksheet cell semantics."""
|
||||
files = unpack(data)
|
||||
if mutation == 'zip-store':
|
||||
return fixed_zip(files, zipfile.ZIP_STORED)
|
||||
if mutation == 'zip-reverse-comment':
|
||||
return fixed_zip(files, reverse=True, comment=b'Excel fuzz archive comment')
|
||||
if mutation == 'sheet-prefix':
|
||||
path = 'xl/worksheets/sheet1.xml'
|
||||
xml = files[path].decode()
|
||||
xml = xml.replace(f'xmlns="{MAIN_NS}"', f'xmlns:s="{MAIN_NS}"')
|
||||
files[path] = re.sub(r'<(/?)([A-Za-z_][\w.-]*)(?=[\s/>])', r'<\1s:\2', xml).encode()
|
||||
if mutation == 'sheet-filename':
|
||||
old, new = 'xl/worksheets/sheet1.xml', 'xl/worksheets/budget.xml'
|
||||
files[new] = files.pop(old)
|
||||
rel = 'xl/worksheets/_rels/sheet1.xml.rels'
|
||||
if rel in files:
|
||||
files['xl/worksheets/_rels/budget.xml.rels'] = files.pop(rel)
|
||||
for path in ['xl/_rels/workbook.xml.rels', '[Content_Types].xml']:
|
||||
files[path] = files[path].replace(b'worksheets/sheet1.xml', b'worksheets/budget.xml')
|
||||
if mutation == 'relationship-dot':
|
||||
path = 'xl/_rels/workbook.xml.rels'
|
||||
files[path] = files[path].replace(b'Target="worksheets/', b'Target="./worksheets/').replace(b'Target="/xl/worksheets/', b'Target="/xl/./worksheets/')
|
||||
if mutation == 'relationship-absolute':
|
||||
path = 'xl/_rels/workbook.xml.rels'
|
||||
files[path] = files[path].replace(b'Target="worksheets/', b'Target="/xl/worksheets/')
|
||||
if mutation in ['drawing-default', 'drawing-alternate']:
|
||||
for path in list(files):
|
||||
if re.fullmatch(r'xl/drawings/[^/]+\.xml', path):
|
||||
xml = files[path].decode()
|
||||
match = re.search(r'xmlns(?::([\w]+))?="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing"', xml)
|
||||
if match:
|
||||
old = match.group(1)
|
||||
new = '' if mutation == 'drawing-default' else 'draw'
|
||||
xml = xml.replace(match.group(0), f'xmlns{":" + new if new else ""}="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing"')
|
||||
if old:
|
||||
xml = re.sub(r'<(/?)' + re.escape(old) + ':', r'<\1' + (new + ':' if new else ''), xml)
|
||||
elif new:
|
||||
xml = re.sub(r'<(/?)([A-Za-z_][\w.-]*)(?=[\s/>])', r'<\1' + new + r':\2', xml)
|
||||
files[path] = xml.encode()
|
||||
return fixed_zip(files)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('output', type=Path)
|
||||
parser.add_argument('--seed', type=int, default=4863)
|
||||
parser.add_argument('--random-cases', type=int, default=300, help='Cases per independent writer')
|
||||
args = parser.parse_args()
|
||||
args.output.mkdir(parents=True, exist_ok=False)
|
||||
rng = random.Random(args.seed)
|
||||
manifest = {'seed': args.seed, 'writers': {'openpyxl': openpyxl.__version__, 'xlsxwriter': xlsxwriter.__version__}, 'cases': [], 'generatorErrors': []}
|
||||
base_values = [(0, 0, 'Item', 'value'), (0, 1, 'Amount', 'value'), (1, 0, 'A', 'value'),
|
||||
(1, 1, 42, 'value'), (2, 0, 'B', 'value'), (2, 1, 15, 'value')]
|
||||
directed = [([], base_values, 'plain')]
|
||||
directed += [([feature], base_values, feature) for feature in FEATURES]
|
||||
directed += [(['chart', feature], base_values, 'chart-' + feature) for feature in ['comment', 'image', 'conditional']]
|
||||
directed += [([], base_values + [(4, 0, error, 'error')], 'error-' + str(index)) for index, error in enumerate(ERRORS)]
|
||||
directed += [([], base_values + [(4, 0, ('=1+2', value), 'formula')], 'cache-' + str(index)) for index, value in enumerate([3, 0, False, True, '', '文本', '#DIV/0!'])]
|
||||
directed += [([epoch] if epoch else [], base_values + [(4, 0, value, kind)], f'temporal-{epoch}-{index}') for epoch in ['', 'epoch1904'] for index, (value, kind) in enumerate([
|
||||
(datetime(2024, 2, 29, 12, 34, 56), 'date'), (time(12, 34, 56), 'time'), (timedelta(days=2, hours=3), 'time')])]
|
||||
directed += [([], base_values + [(4, 0, value, 'value')], 'text-' + str(index)) for index, value in enumerate(['<pic/><chart/> & 字符', '😀\n多行\t数据', '_x0041_', '_x000D_', '00123'])]
|
||||
|
||||
def emit(data, cells, expected_features, writer, features, variant, valid=True):
|
||||
index = len(manifest['cases'])
|
||||
path = f'{index:05d}-{writer}-{variant}.xlsx'
|
||||
if valid:
|
||||
validate(data, cells)
|
||||
(args.output / path).write_bytes(data)
|
||||
manifest['cases'].append({'file': path, 'writer': writer, 'features': sorted(set(features)), 'variant': variant,
|
||||
'valid': valid, 'cells': cells, 'unsupportedFeatures': expected_features})
|
||||
|
||||
for writer in ['openpyxl', 'xlsxwriter']:
|
||||
writer_directed = directed + ([(['textbox'], base_values, 'textbox')] if writer == 'xlsxwriter' else [])
|
||||
for features, values, label in writer_directed:
|
||||
try:
|
||||
data, cells, expected = workbook_bytes(writer, features, values, args.seed)
|
||||
emit(data, cells, expected, writer, features, label)
|
||||
if label in ['plain', 'chart', 'image', 'comment', 'conditional', 'chart-comment']:
|
||||
for mutation in ['zip-store', 'zip-reverse-comment', 'sheet-prefix', 'sheet-filename', 'relationship-dot', 'relationship-absolute', 'drawing-default', 'drawing-alternate']:
|
||||
try:
|
||||
emit(mutate(data, mutation), cells, expected, writer, features, label + '--' + mutation)
|
||||
except Exception as error:
|
||||
manifest['generatorErrors'].append({'writer': writer, 'case': label, 'mutation': mutation, 'error': str(error)})
|
||||
except Exception as error:
|
||||
manifest['generatorErrors'].append({'writer': writer, 'case': label, 'error': str(error)})
|
||||
for index in range(args.random_cases):
|
||||
features = [feature for feature in FEATURES + (['textbox'] if writer == 'xlsxwriter' else []) if rng.random() < 0.3]
|
||||
values = copy.deepcopy(base_values)
|
||||
for row in range(5, rng.randint(6, 14)):
|
||||
value = rng.choice([rng.randint(-100000, 100000), rng.random() * 100, True, False, '中文😀', '<chart>literal</chart>', '00123'])
|
||||
values.append((row, rng.randrange(4), value, 'value'))
|
||||
values += [(3, 3, rng.choice(ERRORS), 'error')]
|
||||
try:
|
||||
data, cells, expected = workbook_bytes(writer, features, values, args.seed + index)
|
||||
mutation = rng.choice(['none', 'zip-store', 'zip-reverse-comment', 'drawing-default', 'drawing-alternate'])
|
||||
emit(mutate(data, mutation), cells, expected, writer, features, f'random-{index}-{mutation}')
|
||||
except Exception as error:
|
||||
manifest['generatorErrors'].append({'writer': writer, 'case': index, 'error': str(error)})
|
||||
|
||||
plain = next(case for case in manifest['cases'] if case['variant'] == 'plain')
|
||||
source = (args.output / plain['file']).read_bytes()
|
||||
for index in range(128):
|
||||
data = bytearray(source)
|
||||
if index < 32:
|
||||
data = data[:rng.randrange(1, len(data))]
|
||||
else:
|
||||
for _ in range(rng.randint(1, 5)):
|
||||
data[rng.randrange(len(data))] ^= rng.randint(1, 255)
|
||||
try:
|
||||
validate(bytes(data), plain['cells'])
|
||||
valid = True
|
||||
except Exception:
|
||||
valid = False
|
||||
emit(bytes(data), plain['cells'], [], 'mutated', [], f'bytes-{index}', valid=valid)
|
||||
|
||||
(args.output / 'manifest.json').write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
|
||||
print(json.dumps({'output': str(args.output), 'cases': len(manifest['cases']), 'valid': sum(case['valid'] for case in manifest['cases']), 'generatorErrors': len(manifest['generatorErrors'])}))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,177 @@
|
||||
/** Explicit XLSX fuzz campaign with per-input worker deadlines and retained failure evidence. */
|
||||
import { readFile, writeFile, mkdir, copyFile, mkdtemp, rm } from 'node:fs/promises'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { resolve, join, relative, isAbsolute } from 'node:path'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { Worker } from 'node:worker_threads'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { createHash } from 'node:crypto'
|
||||
import type { ExcelPreview } from '../../src/client/excel/model.ts'
|
||||
|
||||
interface OracleCell { sheet: string; r: number; c: number; value: string | number | boolean | null; formula?: string }
|
||||
interface Case {
|
||||
file: string
|
||||
writer: string
|
||||
features: string[]
|
||||
variant: string
|
||||
valid: boolean
|
||||
cells: OracleCell[]
|
||||
unsupportedFeatures: string[]
|
||||
}
|
||||
interface Manifest { seed: number; writers: Record<string, string>; cases: Case[]; generatorErrors: unknown[] }
|
||||
type Reply = ({ ok: true; preview: ExcelPreview } | { ok: false; causes: string[] }) & { inputUnchanged?: boolean }
|
||||
interface Outcome {
|
||||
file: string
|
||||
writer: string
|
||||
features: string[]
|
||||
variant: string
|
||||
valid: boolean
|
||||
status: string
|
||||
detail: string
|
||||
group: string
|
||||
elapsedMs: number
|
||||
opened: boolean
|
||||
sha256: string
|
||||
}
|
||||
|
||||
const { values, positionals } = parseArgs({
|
||||
allowPositionals: true,
|
||||
options: { only: { type: 'string' }, 'timeout-ms': { type: 'string', default: '10000' }, report: { type: 'string', default: 'results.json' }, 'baseline-ref': { type: 'string' } },
|
||||
})
|
||||
if (positionals.length !== 1) throw new Error('Usage: pnpm exec tsx packages/client/ui-sidebar-documentpreview/tests/fuzz/run.client.ts <corpus> [--only substring]')
|
||||
const directory = resolve(positionals[0]!)
|
||||
const deadlineMs = Number(values['timeout-ms'])
|
||||
if (!Number.isSafeInteger(deadlineMs) || deadlineMs <= 0) throw new Error('timeout-ms must be a positive integer')
|
||||
const manifest = JSON.parse(await readFile(join(directory, 'manifest.json'), 'utf8')) as Manifest
|
||||
const cases = manifest.cases.filter(test => values.only === undefined || test.file.includes(values.only))
|
||||
if (cases.length === 0) throw new Error('No cases selected')
|
||||
const head = execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim()
|
||||
const artifactRoot = fileURLToPath(new URL('../../.artifacts/', import.meta.url))
|
||||
let worker: Worker | undefined
|
||||
let baselineDirectory: string | undefined
|
||||
let source: string | undefined
|
||||
let parserRef = head
|
||||
if (values['baseline-ref'] !== undefined) {
|
||||
parserRef = execFileSync('git', ['rev-parse', '--verify', `${values['baseline-ref']}^{commit}`], { encoding: 'utf8' }).trim()
|
||||
const sourceRoot = new URL('../../src/client/excel/', import.meta.url).href
|
||||
const baseline = execFileSync('git', ['show', `${parserRef}:packages/client/ui-sidebar-documentpreview/src/client/excel/xlsx.ts`], { encoding: 'utf8' }).replaceAll("from './", `from '${sourceRoot}`)
|
||||
await mkdir(artifactRoot, { recursive: true })
|
||||
baselineDirectory = await mkdtemp(join(artifactRoot, 'excel-baseline-'))
|
||||
const path = join(baselineDirectory, 'xlsx.ts')
|
||||
await writeFile(path, baseline)
|
||||
source = pathToFileURL(path).href
|
||||
}
|
||||
|
||||
async function parse(bytes: Uint8Array<ArrayBuffer>): Promise<Reply> {
|
||||
// Source conversion is the subject; a dedicated worker can be terminated even during synchronous ZIP/XML work.
|
||||
worker ??= new Worker(new URL('./excel-worker.client.ts', import.meta.url), { execArgv: ['--import', 'tsx/esm'], workerData: { source }, resourceLimits: { maxOldGenerationSizeMb: 256 } })
|
||||
const current = worker
|
||||
return new Promise((resolveReply) => {
|
||||
const cleanup = (): void => {
|
||||
clearTimeout(timer)
|
||||
current.off('message', message)
|
||||
current.off('error', error)
|
||||
current.off('exit', exit)
|
||||
}
|
||||
const message = (reply: Reply): void => { cleanup(); resolveReply(reply) }
|
||||
const error = (cause: Error): void => {
|
||||
cleanup()
|
||||
worker = undefined
|
||||
void current.terminate().then(() => { resolveReply({ ok: false, causes: [`WorkerError: ${cause.message}`] }) })
|
||||
}
|
||||
const exit = (code: number): void => {
|
||||
cleanup()
|
||||
worker = undefined
|
||||
resolveReply({ ok: false, causes: [`WorkerExit: ${code}`] })
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
cleanup()
|
||||
worker = undefined
|
||||
void current.terminate().then(() => { resolveReply({ ok: false, causes: [`Timeout: ${deadlineMs}ms`] }) })
|
||||
}, deadlineMs)
|
||||
current.once('message', message)
|
||||
current.once('error', error)
|
||||
current.once('exit', exit)
|
||||
current.postMessage(bytes, [bytes.buffer])
|
||||
})
|
||||
}
|
||||
|
||||
function inspect(test: Case, reply: Reply): Pick<Outcome, 'status' | 'detail' | 'group'> {
|
||||
if (reply.inputUnchanged === false) return { status: 'mutated-source', detail: 'Conversion mutated the borrowed input bytes', group: 'mutated-source' }
|
||||
if (!reply.ok) {
|
||||
const detail = reply.causes.join(' <- ')
|
||||
const controlled = /^ExcelPreviewError: (invalid|tooLarge|timeout|encoding)$/.test(reply.causes[0] ?? '')
|
||||
const status = test.valid ? 'rejected-valid' : controlled ? 'pass' : 'unclassified-error'
|
||||
const variant = test.variant.includes('sheet-prefix') ? 'sheet-prefix' : test.variant.includes('sheet-filename') ? 'sheet-filename' : test.variant.includes('relationship-dot') ? 'relationship-dot' : 'content'
|
||||
return { status, detail, group: `${status}:${variant}:${reply.causes.at(-1)}` }
|
||||
}
|
||||
if (!test.valid) return { status: 'accepted-corrupt', detail: 'Independent reader or ZIP relationship validation rejected this input; permissive acceptance requires inspection.', group: 'accepted-corrupt' }
|
||||
for (const expected of test.cells) {
|
||||
const sheet = reply.preview.sheets.find(sheet => sheet.name === expected.sheet)
|
||||
const cell = sheet?.celldata?.find(cell => cell.r === expected.r && cell.c === expected.c)?.v
|
||||
const actual = cell?.v ?? null
|
||||
const same = typeof expected.value === 'number' && typeof actual === 'number'
|
||||
? Math.abs(actual - expected.value) <= Math.max(1, Math.abs(expected.value)) * 1e-12
|
||||
: actual === expected.value
|
||||
if (!same || (expected.formula !== undefined && expected.formula !== cell?.f)) {
|
||||
const detail = JSON.stringify({ expected, actual: cell ?? null })
|
||||
const category = expected.formula !== undefined ? 'formula-cache' : test.variant.startsWith('temporal') ? 'temporal' : 'value'
|
||||
return { status: 'value-mismatch', detail, group: `value-mismatch:${category}:${JSON.stringify(expected.value)}:${JSON.stringify(actual)}` }
|
||||
}
|
||||
}
|
||||
if (values['baseline-ref'] !== undefined) return { status: 'pass', detail: '', group: 'pass' }
|
||||
const actual = reply.preview.unsupportedFeatures
|
||||
if (new Set(actual).size !== actual.length || JSON.stringify(actual) !== JSON.stringify(test.unsupportedFeatures)) {
|
||||
const detail = JSON.stringify({ expected: test.unsupportedFeatures, actual })
|
||||
return { status: 'notice-mismatch', detail, group: `notice-mismatch:${detail}` }
|
||||
}
|
||||
return { status: 'pass', detail: '', group: 'pass' }
|
||||
}
|
||||
|
||||
const outcomes: Outcome[] = []
|
||||
try {
|
||||
for (const test of cases) {
|
||||
const bytes = new Uint8Array(await readFile(join(directory, test.file)))
|
||||
const sha256 = createHash('sha256').update(bytes).digest('hex')
|
||||
const start = performance.now()
|
||||
const reply = await parse(bytes)
|
||||
outcomes.push({
|
||||
file: test.file, writer: test.writer, features: test.features, variant: test.variant, valid: test.valid,
|
||||
...inspect(test, reply), elapsedMs: Math.round(performance.now() - start), opened: reply.ok, sha256,
|
||||
})
|
||||
if (outcomes.length % 100 === 0) console.log(JSON.stringify({ completed: outcomes.length, total: cases.length, findings: outcomes.filter(item => item.status !== 'pass').length }))
|
||||
}
|
||||
} finally {
|
||||
await worker?.terminate()
|
||||
if (baselineDirectory !== undefined) {
|
||||
const child = relative(artifactRoot, baselineDirectory)
|
||||
if (child === '' || child.startsWith('..') || isAbsolute(child)) throw new Error('Baseline cleanup escaped its artifact directory')
|
||||
await rm(baselineDirectory, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
const counts: Record<string, number> = {}
|
||||
const groups = new Map<string, Outcome[]>()
|
||||
for (const outcome of outcomes) {
|
||||
counts[outcome.status] = (counts[outcome.status] ?? 0) + 1
|
||||
if (outcome.status !== 'pass') groups.set(outcome.group, [...(groups.get(outcome.group) ?? []), outcome])
|
||||
}
|
||||
const representatives: { group: string; count: number; example: string; detail: string }[] = []
|
||||
await mkdir(join(directory, 'repros'), { recursive: true })
|
||||
for (const [group, members] of groups) {
|
||||
const sorted = [...members].sort((a, b) => a.features.length - b.features.length
|
||||
|| Number(a.variant.includes('random')) - Number(b.variant.includes('random')) || a.file.localeCompare(b.file))
|
||||
const first = sorted[0]!
|
||||
await copyFile(join(directory, first.file), join(directory, 'repros', first.file))
|
||||
representatives.push({ group, count: members.length, example: `repros/${first.file}`, detail: first.detail })
|
||||
}
|
||||
const report = {
|
||||
head, parserRef, seed: manifest.seed, writers: manifest.writers, selected: cases.length, counts,
|
||||
worktreeStatus: execFileSync('git', ['status', '--short'], { encoding: 'utf8' }).trim(),
|
||||
validOpened: outcomes.filter(outcome => outcome.valid && outcome.opened).length,
|
||||
validRejected: outcomes.filter(outcome => outcome.valid && !outcome.opened).length,
|
||||
generatorErrors: manifest.generatorErrors, representatives, outcomes,
|
||||
}
|
||||
await writeFile(join(directory, values.report), `${JSON.stringify(report, null, 2)}\n`)
|
||||
console.log(JSON.stringify({ report: join(directory, values.report), head, selected: cases.length, counts, representatives }, null, 2))
|
||||
if (outcomes.some(outcome => outcome.status !== 'pass' && outcome.status !== 'accepted-corrupt')) process.exitCode = 1
|
||||
@@ -0,0 +1,806 @@
|
||||
diff --git a/dist/exceljs.js b/dist/exceljs.js
|
||||
index ab2c9d6e576d8069d77d787b863dfe09c7659890..99213b8606d02b8948ce6ce44a5a7dcfc203d854 100644
|
||||
--- a/dist/exceljs.js
|
||||
+++ b/dist/exceljs.js
|
||||
@@ -4636,24 +4636,37 @@ module.exports = async function* (iterable) {
|
||||
if (iterable.pipe && !iterable[Symbol.asyncIterator]) {
|
||||
iterable = iterable.pipe(new PassThrough());
|
||||
}
|
||||
- const saxesParser = new SaxesParser();
|
||||
+ const saxesParser = new SaxesParser({xmlns: true});
|
||||
+ // Xforms use canonical names; OOXML producers may choose different prefixes.
|
||||
+ const prefixes = new Map([
|
||||
+ ['http://schemas.openxmlformats.org/spreadsheetml/2006/main', ''],
|
||||
+ ['http://purl.oclc.org/ooxml/spreadsheetml/main', ''],
|
||||
+ ['http://schemas.openxmlformats.org/package/2006/relationships', ''],
|
||||
+ ['http://schemas.openxmlformats.org/officeDocument/2006/relationships', 'r'],
|
||||
+ ['http://purl.oclc.org/ooxml/officeDocument/relationships', 'r'],
|
||||
+ ['urn:schemas-microsoft-com:vml', 'v'],
|
||||
+ ['urn:schemas-microsoft-com:office:office', 'o'],
|
||||
+ ['urn:schemas-microsoft-com:office:excel', 'x'],
|
||||
+ ]);
|
||||
+ const canonicalName = node => {
|
||||
+ const prefix = prefixes.get(node.uri);
|
||||
+ return prefix === undefined ? node.name : prefix ? `${prefix}:${node.local}` : node.local;
|
||||
+ };
|
||||
let error;
|
||||
saxesParser.on('error', err => {
|
||||
error = err;
|
||||
});
|
||||
let events = [];
|
||||
- saxesParser.on('opentag', value => events.push({
|
||||
- eventType: 'opentag',
|
||||
- value
|
||||
- }));
|
||||
- saxesParser.on('text', value => events.push({
|
||||
- eventType: 'text',
|
||||
- value
|
||||
- }));
|
||||
- saxesParser.on('closetag', value => events.push({
|
||||
- eventType: 'closetag',
|
||||
- value
|
||||
- }));
|
||||
+ saxesParser.on('opentag', node => {
|
||||
+ const attributes = Object.create(null);
|
||||
+ Object.values(node.attributes).forEach(attribute => {
|
||||
+ attributes[canonicalName(attribute)] = attribute.value;
|
||||
+ });
|
||||
+ events.push({eventType: 'opentag', value: {...node, name: canonicalName(node), attributes}});
|
||||
+ });
|
||||
+ saxesParser.on('text', value => events.push({eventType: 'text', value}));
|
||||
+ saxesParser.on('cdata', value => events.push({eventType: 'text', value}));
|
||||
+ saxesParser.on('closetag', node => events.push({eventType: 'closetag', value: {...node, name: canonicalName(node)}}));
|
||||
for await (const chunk of iterable) {
|
||||
saxesParser.write(bufferToString(chunk));
|
||||
// saxesParser.write and saxesParser.on() are synchronous,
|
||||
@@ -6477,12 +6490,11 @@ class WorkbookXform extends BaseXform {
|
||||
let index = 0;
|
||||
(model.sheets || []).forEach(sheet => {
|
||||
const rel = rels[sheet.rId];
|
||||
- if (!rel) {
|
||||
- return;
|
||||
+ if (!rel) throw new Error(`Missing worksheet relationship: ${sheet.rId}`);
|
||||
+ worksheet = model.worksheetHash[rel.Target];
|
||||
+ if (!worksheet && rel.Type === 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet') {
|
||||
+ throw new Error(`Missing worksheet: ${rel.Target}`);
|
||||
}
|
||||
- // if rel.Target start with `[space]/xl/` or `/xl/` , then it will be replaced with `''` and spliced behind `xl/`,
|
||||
- // otherwise it will be spliced directly behind `xl/`. i.g.
|
||||
- worksheet = model.worksheetHash[`xl/${rel.Target.replace(/^(\s|\/xl\/)+/, '')}`];
|
||||
// If there are "chartsheets" in the file, rel.Target will
|
||||
// come out as chartsheets/sheet1.xml or similar here, and
|
||||
// that won't be in model.worksheetHash.
|
||||
@@ -6635,6 +6647,11 @@ utils.inherits(CommentXform, BaseXform, {
|
||||
...node.attributes
|
||||
};
|
||||
return true;
|
||||
+ case 't':
|
||||
+ this.plainText = true;
|
||||
+ this.parser = this.richTextXform.textXform;
|
||||
+ this.parser.parseOpen(node);
|
||||
+ return true;
|
||||
case 'r':
|
||||
this.parser = this.richTextXform;
|
||||
this.parser.parseOpen(node);
|
||||
@@ -6649,6 +6666,12 @@ utils.inherits(CommentXform, BaseXform, {
|
||||
}
|
||||
},
|
||||
parseClose(name) {
|
||||
+ if (this.plainText && name === 't') {
|
||||
+ this.model.note.texts.push({text: this.parser.model});
|
||||
+ this.parser = undefined;
|
||||
+ this.plainText = false;
|
||||
+ return true;
|
||||
+ }
|
||||
switch (name) {
|
||||
case 'comment':
|
||||
return false;
|
||||
@@ -12386,17 +12409,17 @@ class WorkSheetXform extends BaseXform {
|
||||
// options.merges.reconcile(model.mergeCells, model.rows);
|
||||
const rels = (model.relationships || []).reduce((h, rel) => {
|
||||
h[rel.Id] = rel;
|
||||
- if (rel.Type === RelType.Comments) {
|
||||
- model.comments = options.comments[rel.Target].comments;
|
||||
- }
|
||||
- if (rel.Type === RelType.VmlDrawing && model.comments && model.comments.length) {
|
||||
- const vmlComment = options.vmlDrawings[rel.Target].comments;
|
||||
- model.comments.forEach((comment, index) => {
|
||||
- comment.note = Object.assign({}, comment.note, vmlComment[index]);
|
||||
- });
|
||||
- }
|
||||
return h;
|
||||
}, {});
|
||||
+ const commentRel = Object.values(rels).find(rel => rel.Type === RelType.Comments);
|
||||
+ if (commentRel) model.comments = options.comments[commentRel.Target].comments;
|
||||
+ const vmlRel = Object.values(rels).find(rel => rel.Type === RelType.VmlDrawing);
|
||||
+ if (vmlRel && model.comments && model.comments.length) {
|
||||
+ const vmlComments = options.vmlDrawings[vmlRel.Target].comments;
|
||||
+ model.comments.forEach((comment, index) => {
|
||||
+ comment.note = Object.assign({}, comment.note, vmlComments[index]);
|
||||
+ });
|
||||
+ }
|
||||
options.commentsMap = (model.comments || []).reduce((h, comment) => {
|
||||
if (comment.ref) {
|
||||
h[comment.ref] = comment;
|
||||
@@ -15491,9 +15514,7 @@ const ZipStream = require('../utils/zip-stream');
|
||||
const StreamBuf = require('../utils/stream-buf');
|
||||
const utils = require('../utils/utils');
|
||||
const XmlStream = require('../utils/xml-stream');
|
||||
-const {
|
||||
- bufferToString
|
||||
-} = require('../utils/browser-buffer-decode');
|
||||
+
|
||||
const StylesXform = require('./xform/style/styles-xform');
|
||||
const CoreXform = require('./xform/core/core-xform');
|
||||
const SharedStringsXform = require('./xform/strings/shared-strings-xform');
|
||||
@@ -15621,6 +15642,31 @@ class XLSX {
|
||||
delete model.drawingRels;
|
||||
delete model.vmlDrawings;
|
||||
}
|
||||
+ // XML processors must accept UTF-8 and both byte orders of UTF-16.
|
||||
+ async _readXmlEntry(entry) {
|
||||
+ const bytes = await entry.async('uint8array');
|
||||
+ const encoding = (bytes[0] === 0xff && bytes[1] === 0xfe) || (bytes[0] === 0x3c && bytes[1] === 0)
|
||||
+ ? 'utf-16le'
|
||||
+ : (bytes[0] === 0xfe && bytes[1] === 0xff) || (bytes[0] === 0 && bytes[1] === 0x3c)
|
||||
+ ? 'utf-16be' : 'utf-8';
|
||||
+ return new TextDecoder(encoding, {fatal: true}).decode(bytes);
|
||||
+ }
|
||||
+
|
||||
+ // Resolve an internal OPC target against its owning part, without filesystem access.
|
||||
+ _resolvePart(owner, target) {
|
||||
+ const parts = target.startsWith('/') ? [] : owner.split('/').slice(0, -1);
|
||||
+ for (const segment of target.split('/')) {
|
||||
+ if (!segment || segment === '.') continue;
|
||||
+ if (segment === '..') {
|
||||
+ if (!parts.length) throw new Error(`Relationship escapes package: ${target}`);
|
||||
+ parts.pop();
|
||||
+ } else {
|
||||
+ parts.push(segment);
|
||||
+ }
|
||||
+ }
|
||||
+ return parts.join('/');
|
||||
+ }
|
||||
+
|
||||
async _processWorksheetEntry(stream, model, sheetNo, options, path) {
|
||||
const xform = new WorksheetXform(options);
|
||||
const worksheet = await xform.parseStream(stream);
|
||||
@@ -15631,18 +15677,14 @@ class XLSX {
|
||||
async _processCommentEntry(stream, model, name) {
|
||||
const xform = new CommentsXform();
|
||||
const comments = await xform.parseStream(stream);
|
||||
- model.comments[`../${name}.xml`] = comments;
|
||||
+ model.comments[name] = comments;
|
||||
}
|
||||
async _processTableEntry(stream, model, name) {
|
||||
const xform = new TableXform();
|
||||
const table = await xform.parseStream(stream);
|
||||
- model.tables[`../tables/${name}.xml`] = table;
|
||||
- }
|
||||
- async _processWorksheetRelsEntry(stream, model, sheetNo) {
|
||||
- const xform = new RelationshipsXform();
|
||||
- const relationships = await xform.parseStream(stream);
|
||||
- model.worksheetRels[sheetNo] = relationships;
|
||||
+ model.tables[name] = table;
|
||||
}
|
||||
+
|
||||
async _processMediaEntry(entry, model, filename) {
|
||||
const lastDot = filename.lastIndexOf('.');
|
||||
// if we can't determine extension, ignore it
|
||||
@@ -15683,7 +15725,7 @@ class XLSX {
|
||||
async _processVmlDrawingEntry(entry, model, name) {
|
||||
const xform = new VmlNotesXform();
|
||||
const vmlDrawing = await xform.parseStream(entry);
|
||||
- model.vmlDrawings[`../drawings/${name}.vml`] = vmlDrawing;
|
||||
+ model.vmlDrawings[name] = vmlDrawing;
|
||||
}
|
||||
async _processThemeEntry(entry, model, name) {
|
||||
await new Promise((resolve, reject) => {
|
||||
@@ -15727,7 +15769,7 @@ class XLSX {
|
||||
const model = {
|
||||
worksheets: [],
|
||||
worksheetHash: {},
|
||||
- worksheetRels: [],
|
||||
+ worksheetRels: Object.create(null),
|
||||
themes: {},
|
||||
media: [],
|
||||
mediaIndex: {},
|
||||
@@ -15738,6 +15780,49 @@ class XLSX {
|
||||
vmlDrawings: {}
|
||||
};
|
||||
const zip = await JSZip.loadAsync(buffer);
|
||||
+ const partKey = path => path.replace(/[A-Z]/g, character => character.toLowerCase());
|
||||
+ const partNames = new Map();
|
||||
+ for (const entry of Object.values(zip.files)) {
|
||||
+ if (entry.dir) continue;
|
||||
+ const key = partKey(entry.name);
|
||||
+ if (partNames.has(key)) throw new Error(`Ambiguous workbook part: ${entry.name}`);
|
||||
+ partNames.set(key, entry.name);
|
||||
+ }
|
||||
+ const partTypes = new Map();
|
||||
+ const relationships = new Map();
|
||||
+ const resolvedTypes = new Set([
|
||||
+ XLSX.RelType.OfficeDocument, XLSX.RelType.Styles, XLSX.RelType.SharedStrings,
|
||||
+ XLSX.RelType.Worksheet, XLSX.RelType.Comments, XLSX.RelType.Table, XLSX.RelType.VmlDrawing,
|
||||
+ ]);
|
||||
+ // Relationship order and ZIP entry order do not determine part identity.
|
||||
+ for (const entry of Object.values(zip.files)) {
|
||||
+ const match = entry.name.match(/^(.*\/)?_rels\/([^/]+)\.rels$/i);
|
||||
+ if (entry.dir || (!match && partKey(entry.name) !== '_rels/.rels')) continue;
|
||||
+ const owner = match ? `${match[1] || ''}${match[2]}` : '';
|
||||
+ const rels = await this.parseRels([await this._readXmlEntry(entry)]);
|
||||
+ if (!rels) throw new Error(`Invalid relationships: ${entry.name}`);
|
||||
+ for (const rel of rels) {
|
||||
+ rel.Type = rel.Type.replace(/^http:\/\/purl\.oclc\.org\/ooxml\/officeDocument\/relationships\//,
|
||||
+ 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/');
|
||||
+ if (!resolvedTypes.has(rel.Type)) continue;
|
||||
+ if (rel.TargetMode === 'External') throw new Error(`External workbook part: ${rel.Target}`);
|
||||
+ const target = this._resolvePart(owner, rel.Target);
|
||||
+ rel.Target = partNames.get(partKey(target));
|
||||
+ if (!rel.Target) throw new Error(`Missing workbook part: ${target}`);
|
||||
+ const type = partTypes.get(rel.Target);
|
||||
+ if (type && type !== rel.Type) throw new Error(`Conflicting workbook part: ${rel.Target}`);
|
||||
+ partTypes.set(rel.Target, rel.Type);
|
||||
+ }
|
||||
+ relationships.set(partKey(owner), rels);
|
||||
+ }
|
||||
+ model.globalRels = relationships.get('') || [];
|
||||
+ const officeDocuments = model.globalRels.filter(rel => rel.Type === XLSX.RelType.OfficeDocument);
|
||||
+ if (officeDocuments.length !== 1) throw new Error('Expected one workbook relationship');
|
||||
+ model.workbookRels = relationships.get(partKey(officeDocuments[0].Target));
|
||||
+ if (!model.workbookRels) throw new Error('Missing workbook relationships');
|
||||
+ for (const [path, type] of partTypes) {
|
||||
+ if (type === XLSX.RelType.Worksheet) model.worksheetRels[path] = relationships.get(partKey(path));
|
||||
+ }
|
||||
for (const entry of Object.values(zip.files)) {
|
||||
/* eslint-disable no-await-in-loop */
|
||||
if (!entry.dir) {
|
||||
@@ -15745,10 +15830,9 @@ class XLSX {
|
||||
if (entryName[0] === '/') {
|
||||
entryName = entryName.substr(1);
|
||||
}
|
||||
+ if (!partTypes.has(entryName) && !/\.(xml|rels|vml)$/i.test(entryName) && !entryName.match(/xl\/media\//)) continue;
|
||||
let stream;
|
||||
- if (entryName.match(/xl\/media\//) ||
|
||||
- // themes are not parsed as stream
|
||||
- entryName.match(/xl\/theme\/([a-zA-Z0-9]+)[.]xml/)) {
|
||||
+ if (entryName.match(/xl\/media\//)) {
|
||||
stream = new PassThrough();
|
||||
stream.write(await entry.async('nodebuffer'));
|
||||
} else {
|
||||
@@ -15757,46 +15841,48 @@ class XLSX {
|
||||
writableObjectMode: true,
|
||||
readableObjectMode: true
|
||||
});
|
||||
- let content;
|
||||
- // https://www.npmjs.com/package/process
|
||||
- if (process.browser) {
|
||||
- // running in browser, use TextDecoder if possible
|
||||
- content = bufferToString(await entry.async('nodebuffer'));
|
||||
- } else {
|
||||
- // running in node.js
|
||||
- content = await entry.async('string');
|
||||
- }
|
||||
+ const content = await this._readXmlEntry(entry);
|
||||
const chunkSize = 16 * 1024;
|
||||
for (let i = 0; i < content.length; i += chunkSize) {
|
||||
stream.write(content.substring(i, i + chunkSize));
|
||||
}
|
||||
}
|
||||
stream.end();
|
||||
- switch (entryName) {
|
||||
- case '_rels/.rels':
|
||||
- model.globalRels = await this.parseRels(stream);
|
||||
- break;
|
||||
- case 'xl/workbook.xml':
|
||||
- {
|
||||
- const workbook = await this.parseWorkbook(stream);
|
||||
- model.sheets = workbook.sheets;
|
||||
- model.definedNames = workbook.definedNames;
|
||||
- model.views = workbook.views;
|
||||
- model.properties = workbook.properties;
|
||||
- model.calcProperties = workbook.calcProperties;
|
||||
- break;
|
||||
- }
|
||||
- case 'xl/_rels/workbook.xml.rels':
|
||||
- model.workbookRels = await this.parseRels(stream);
|
||||
- break;
|
||||
- case 'xl/sharedStrings.xml':
|
||||
+ switch (partTypes.get(entryName)) {
|
||||
+ case XLSX.RelType.OfficeDocument: {
|
||||
+ const workbook = await this.parseWorkbook(stream);
|
||||
+ model.sheets = workbook.sheets;
|
||||
+ model.definedNames = workbook.definedNames;
|
||||
+ model.views = workbook.views;
|
||||
+ model.properties = workbook.properties;
|
||||
+ model.calcProperties = workbook.calcProperties;
|
||||
+ continue;
|
||||
+ }
|
||||
+ case XLSX.RelType.SharedStrings:
|
||||
model.sharedStrings = new SharedStringsXform();
|
||||
await model.sharedStrings.parseStream(stream);
|
||||
- break;
|
||||
- case 'xl/styles.xml':
|
||||
+ continue;
|
||||
+ case XLSX.RelType.Styles:
|
||||
model.styles = new StylesXform();
|
||||
await model.styles.parseStream(stream);
|
||||
+ continue;
|
||||
+ case XLSX.RelType.Worksheet:
|
||||
+ await this._processWorksheetEntry(stream, model, entryName, options, entryName);
|
||||
+ continue;
|
||||
+ case XLSX.RelType.Comments:
|
||||
+ await this._processCommentEntry(stream, model, entryName);
|
||||
+ continue;
|
||||
+ case XLSX.RelType.Table:
|
||||
+ await this._processTableEntry(stream, model, entryName);
|
||||
+ continue;
|
||||
+ case XLSX.RelType.VmlDrawing:
|
||||
+ await this._processVmlDrawingEntry(stream, model, entryName);
|
||||
+ continue;
|
||||
+ default:
|
||||
break;
|
||||
+ }
|
||||
+ switch (entryName) {
|
||||
+
|
||||
case 'docProps/app.xml':
|
||||
{
|
||||
const appXform = new AppXform();
|
||||
@@ -15814,17 +15900,7 @@ class XLSX {
|
||||
}
|
||||
default:
|
||||
{
|
||||
- let match = entryName.match(/xl\/worksheets\/sheet(\d+)[.]xml/);
|
||||
- if (match) {
|
||||
- await this._processWorksheetEntry(stream, model, match[1], options, entryName);
|
||||
- break;
|
||||
- }
|
||||
- match = entryName.match(/xl\/worksheets\/_rels\/sheet(\d+)[.]xml.rels/);
|
||||
- if (match) {
|
||||
- await this._processWorksheetRelsEntry(stream, model, match[1]);
|
||||
- break;
|
||||
- }
|
||||
- match = entryName.match(/xl\/theme\/([a-zA-Z0-9]+)[.]xml/);
|
||||
+ let match = entryName.match(/xl\/theme\/([a-zA-Z0-9]+)[.]xml/);
|
||||
if (match) {
|
||||
await this._processThemeEntry(stream, model, match[1]);
|
||||
break;
|
||||
@@ -15839,26 +15915,14 @@ class XLSX {
|
||||
await this._processDrawingEntry(stream, model, match[1]);
|
||||
break;
|
||||
}
|
||||
- match = entryName.match(/xl\/(comments\d+)[.]xml/);
|
||||
- if (match) {
|
||||
- await this._processCommentEntry(stream, model, match[1]);
|
||||
- break;
|
||||
- }
|
||||
- match = entryName.match(/xl\/tables\/(table\d+)[.]xml/);
|
||||
- if (match) {
|
||||
- await this._processTableEntry(stream, model, match[1]);
|
||||
- break;
|
||||
- }
|
||||
+
|
||||
+
|
||||
match = entryName.match(/xl\/drawings\/_rels\/([a-zA-Z0-9]+)[.]xml[.]rels/);
|
||||
if (match) {
|
||||
await this._processDrawingRelsEntry(stream, model, match[1]);
|
||||
break;
|
||||
}
|
||||
- match = entryName.match(/xl\/drawings\/(vmlDrawing\d+)[.]vml/);
|
||||
- if (match) {
|
||||
- await this._processVmlDrawingEntry(stream, model, match[1]);
|
||||
- break;
|
||||
- }
|
||||
+
|
||||
}
|
||||
}
|
||||
}
|
||||
diff --git a/lib/utils/parse-sax.js b/lib/utils/parse-sax.js
|
||||
index 14c682a731b5e51f999859a03ce4dd4e0bfcbb26..65c0f2b7ddd5c4e0c3a283f74c37a2fd22728aad 100644
|
||||
--- a/lib/utils/parse-sax.js
|
||||
+++ b/lib/utils/parse-sax.js
|
||||
@@ -8,15 +8,37 @@ module.exports = async function* (iterable) {
|
||||
if (iterable.pipe && !iterable[Symbol.asyncIterator]) {
|
||||
iterable = iterable.pipe(new PassThrough());
|
||||
}
|
||||
- const saxesParser = new SaxesParser();
|
||||
+ const saxesParser = new SaxesParser({xmlns: true});
|
||||
+ // Xforms use canonical names; OOXML producers may choose different prefixes.
|
||||
+ const prefixes = new Map([
|
||||
+ ['http://schemas.openxmlformats.org/spreadsheetml/2006/main', ''],
|
||||
+ ['http://purl.oclc.org/ooxml/spreadsheetml/main', ''],
|
||||
+ ['http://schemas.openxmlformats.org/package/2006/relationships', ''],
|
||||
+ ['http://schemas.openxmlformats.org/officeDocument/2006/relationships', 'r'],
|
||||
+ ['http://purl.oclc.org/ooxml/officeDocument/relationships', 'r'],
|
||||
+ ['urn:schemas-microsoft-com:vml', 'v'],
|
||||
+ ['urn:schemas-microsoft-com:office:office', 'o'],
|
||||
+ ['urn:schemas-microsoft-com:office:excel', 'x'],
|
||||
+ ]);
|
||||
+ const canonicalName = node => {
|
||||
+ const prefix = prefixes.get(node.uri);
|
||||
+ return prefix === undefined ? node.name : prefix ? `${prefix}:${node.local}` : node.local;
|
||||
+ };
|
||||
let error;
|
||||
saxesParser.on('error', err => {
|
||||
error = err;
|
||||
});
|
||||
let events = [];
|
||||
- saxesParser.on('opentag', value => events.push({eventType: 'opentag', value}));
|
||||
+ saxesParser.on('opentag', node => {
|
||||
+ const attributes = Object.create(null);
|
||||
+ Object.values(node.attributes).forEach(attribute => {
|
||||
+ attributes[canonicalName(attribute)] = attribute.value;
|
||||
+ });
|
||||
+ events.push({eventType: 'opentag', value: {...node, name: canonicalName(node), attributes}});
|
||||
+ });
|
||||
saxesParser.on('text', value => events.push({eventType: 'text', value}));
|
||||
- saxesParser.on('closetag', value => events.push({eventType: 'closetag', value}));
|
||||
+ saxesParser.on('cdata', value => events.push({eventType: 'text', value}));
|
||||
+ saxesParser.on('closetag', node => events.push({eventType: 'closetag', value: {...node, name: canonicalName(node)}}));
|
||||
for await (const chunk of iterable) {
|
||||
saxesParser.write(bufferToString(chunk));
|
||||
// saxesParser.write and saxesParser.on() are synchronous,
|
||||
diff --git a/lib/xlsx/xform/book/workbook-xform.js b/lib/xlsx/xform/book/workbook-xform.js
|
||||
index 5c10a5857eba8d3fbf9d3841ff15b39aefde8862..c6c1dcd6fe739b37db48ae30d1b039d635e14aa5 100644
|
||||
--- a/lib/xlsx/xform/book/workbook-xform.js
|
||||
+++ b/lib/xlsx/xform/book/workbook-xform.js
|
||||
@@ -165,12 +165,11 @@ class WorkbookXform extends BaseXform {
|
||||
|
||||
(model.sheets || []).forEach(sheet => {
|
||||
const rel = rels[sheet.rId];
|
||||
- if (!rel) {
|
||||
- return;
|
||||
+ if (!rel) throw new Error(`Missing worksheet relationship: ${sheet.rId}`);
|
||||
+ worksheet = model.worksheetHash[rel.Target];
|
||||
+ if (!worksheet && rel.Type === 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet') {
|
||||
+ throw new Error(`Missing worksheet: ${rel.Target}`);
|
||||
}
|
||||
- // if rel.Target start with `[space]/xl/` or `/xl/` , then it will be replaced with `''` and spliced behind `xl/`,
|
||||
- // otherwise it will be spliced directly behind `xl/`. i.g.
|
||||
- worksheet = model.worksheetHash[`xl/${rel.Target.replace(/^(\s|\/xl\/)+/, '')}`];
|
||||
// If there are "chartsheets" in the file, rel.Target will
|
||||
// come out as chartsheets/sheet1.xml or similar here, and
|
||||
// that won't be in model.worksheetHash.
|
||||
diff --git a/lib/xlsx/xform/comment/comment-xform.js b/lib/xlsx/xform/comment/comment-xform.js
|
||||
index 16363bfea8d0903ac66c77606c4362ef3fcd72bb..dc0df108c5433c5447a37ecd163883f5fe24cf32 100644
|
||||
--- a/lib/xlsx/xform/comment/comment-xform.js
|
||||
+++ b/lib/xlsx/xform/comment/comment-xform.js
|
||||
@@ -74,6 +74,11 @@ utils.inherits(CommentXform, BaseXform, {
|
||||
...node.attributes,
|
||||
};
|
||||
return true;
|
||||
+ case 't':
|
||||
+ this.plainText = true;
|
||||
+ this.parser = this.richTextXform.textXform;
|
||||
+ this.parser.parseOpen(node);
|
||||
+ return true;
|
||||
case 'r':
|
||||
this.parser = this.richTextXform;
|
||||
this.parser.parseOpen(node);
|
||||
@@ -88,6 +93,12 @@ utils.inherits(CommentXform, BaseXform, {
|
||||
}
|
||||
},
|
||||
parseClose(name) {
|
||||
+ if (this.plainText && name === 't') {
|
||||
+ this.model.note.texts.push({text: this.parser.model});
|
||||
+ this.parser = undefined;
|
||||
+ this.plainText = false;
|
||||
+ return true;
|
||||
+ }
|
||||
switch (name) {
|
||||
case 'comment':
|
||||
return false;
|
||||
diff --git a/lib/xlsx/xform/sheet/worksheet-xform.js b/lib/xlsx/xform/sheet/worksheet-xform.js
|
||||
index b38930042cbb05dccd01c46a9adacb7a9fc59dba..e0b239143b4e80ecb0c2030c3bb7b48a803ebef8 100644
|
||||
--- a/lib/xlsx/xform/sheet/worksheet-xform.js
|
||||
+++ b/lib/xlsx/xform/sheet/worksheet-xform.js
|
||||
@@ -449,17 +449,17 @@ class WorkSheetXform extends BaseXform {
|
||||
// options.merges.reconcile(model.mergeCells, model.rows);
|
||||
const rels = (model.relationships || []).reduce((h, rel) => {
|
||||
h[rel.Id] = rel;
|
||||
- if (rel.Type === RelType.Comments) {
|
||||
- model.comments = options.comments[rel.Target].comments;
|
||||
- }
|
||||
- if (rel.Type === RelType.VmlDrawing && model.comments && model.comments.length) {
|
||||
- const vmlComment = options.vmlDrawings[rel.Target].comments;
|
||||
- model.comments.forEach((comment, index) => {
|
||||
- comment.note = Object.assign({}, comment.note, vmlComment[index]);
|
||||
- });
|
||||
- }
|
||||
return h;
|
||||
}, {});
|
||||
+ const commentRel = Object.values(rels).find(rel => rel.Type === RelType.Comments);
|
||||
+ if (commentRel) model.comments = options.comments[commentRel.Target].comments;
|
||||
+ const vmlRel = Object.values(rels).find(rel => rel.Type === RelType.VmlDrawing);
|
||||
+ if (vmlRel && model.comments && model.comments.length) {
|
||||
+ const vmlComments = options.vmlDrawings[vmlRel.Target].comments;
|
||||
+ model.comments.forEach((comment, index) => {
|
||||
+ comment.note = Object.assign({}, comment.note, vmlComments[index]);
|
||||
+ });
|
||||
+ }
|
||||
options.commentsMap = (model.comments || []).reduce((h, comment) => {
|
||||
if (comment.ref) {
|
||||
h[comment.ref] = comment;
|
||||
diff --git a/lib/xlsx/xlsx.js b/lib/xlsx/xlsx.js
|
||||
index ff2d9a117d78cacf727564f74770e1e8f19d0fa1..179d1dac2ed02bfc4b81f85afe7abd9822ef1a1d 100644
|
||||
--- a/lib/xlsx/xlsx.js
|
||||
+++ b/lib/xlsx/xlsx.js
|
||||
@@ -6,7 +6,7 @@ const StreamBuf = require('../utils/stream-buf');
|
||||
|
||||
const utils = require('../utils/utils');
|
||||
const XmlStream = require('../utils/xml-stream');
|
||||
-const {bufferToString} = require('../utils/browser-buffer-decode');
|
||||
+
|
||||
|
||||
const StylesXform = require('./xform/style/styles-xform');
|
||||
|
||||
@@ -146,6 +146,31 @@ class XLSX {
|
||||
delete model.vmlDrawings;
|
||||
}
|
||||
|
||||
+ // XML processors must accept UTF-8 and both byte orders of UTF-16.
|
||||
+ async _readXmlEntry(entry) {
|
||||
+ const bytes = await entry.async('uint8array');
|
||||
+ const encoding = (bytes[0] === 0xff && bytes[1] === 0xfe) || (bytes[0] === 0x3c && bytes[1] === 0)
|
||||
+ ? 'utf-16le'
|
||||
+ : (bytes[0] === 0xfe && bytes[1] === 0xff) || (bytes[0] === 0 && bytes[1] === 0x3c)
|
||||
+ ? 'utf-16be' : 'utf-8';
|
||||
+ return new TextDecoder(encoding, {fatal: true}).decode(bytes);
|
||||
+ }
|
||||
+
|
||||
+ // Resolve an internal OPC target against its owning part, without filesystem access.
|
||||
+ _resolvePart(owner, target) {
|
||||
+ const parts = target.startsWith('/') ? [] : owner.split('/').slice(0, -1);
|
||||
+ for (const segment of target.split('/')) {
|
||||
+ if (!segment || segment === '.') continue;
|
||||
+ if (segment === '..') {
|
||||
+ if (!parts.length) throw new Error(`Relationship escapes package: ${target}`);
|
||||
+ parts.pop();
|
||||
+ } else {
|
||||
+ parts.push(segment);
|
||||
+ }
|
||||
+ }
|
||||
+ return parts.join('/');
|
||||
+ }
|
||||
+
|
||||
async _processWorksheetEntry(stream, model, sheetNo, options, path) {
|
||||
const xform = new WorksheetXform(options);
|
||||
const worksheet = await xform.parseStream(stream);
|
||||
@@ -157,19 +182,13 @@ class XLSX {
|
||||
async _processCommentEntry(stream, model, name) {
|
||||
const xform = new CommentsXform();
|
||||
const comments = await xform.parseStream(stream);
|
||||
- model.comments[`../${name}.xml`] = comments;
|
||||
+ model.comments[name] = comments;
|
||||
}
|
||||
|
||||
async _processTableEntry(stream, model, name) {
|
||||
const xform = new TableXform();
|
||||
const table = await xform.parseStream(stream);
|
||||
- model.tables[`../tables/${name}.xml`] = table;
|
||||
- }
|
||||
-
|
||||
- async _processWorksheetRelsEntry(stream, model, sheetNo) {
|
||||
- const xform = new RelationshipsXform();
|
||||
- const relationships = await xform.parseStream(stream);
|
||||
- model.worksheetRels[sheetNo] = relationships;
|
||||
+ model.tables[name] = table;
|
||||
}
|
||||
|
||||
async _processMediaEntry(entry, model, filename) {
|
||||
@@ -215,7 +234,7 @@ class XLSX {
|
||||
async _processVmlDrawingEntry(entry, model, name) {
|
||||
const xform = new VmlNotesXform();
|
||||
const vmlDrawing = await xform.parseStream(entry);
|
||||
- model.vmlDrawings[`../drawings/${name}.vml`] = vmlDrawing;
|
||||
+ model.vmlDrawings[name] = vmlDrawing;
|
||||
}
|
||||
|
||||
async _processThemeEntry(entry, model, name) {
|
||||
@@ -265,7 +284,7 @@ class XLSX {
|
||||
const model = {
|
||||
worksheets: [],
|
||||
worksheetHash: {},
|
||||
- worksheetRels: [],
|
||||
+ worksheetRels: Object.create(null),
|
||||
themes: {},
|
||||
media: [],
|
||||
mediaIndex: {},
|
||||
@@ -277,6 +296,49 @@ class XLSX {
|
||||
};
|
||||
|
||||
const zip = await JSZip.loadAsync(buffer);
|
||||
+ const partKey = path => path.replace(/[A-Z]/g, character => character.toLowerCase());
|
||||
+ const partNames = new Map();
|
||||
+ for (const entry of Object.values(zip.files)) {
|
||||
+ if (entry.dir) continue;
|
||||
+ const key = partKey(entry.name);
|
||||
+ if (partNames.has(key)) throw new Error(`Ambiguous workbook part: ${entry.name}`);
|
||||
+ partNames.set(key, entry.name);
|
||||
+ }
|
||||
+ const partTypes = new Map();
|
||||
+ const relationships = new Map();
|
||||
+ const resolvedTypes = new Set([
|
||||
+ XLSX.RelType.OfficeDocument, XLSX.RelType.Styles, XLSX.RelType.SharedStrings,
|
||||
+ XLSX.RelType.Worksheet, XLSX.RelType.Comments, XLSX.RelType.Table, XLSX.RelType.VmlDrawing,
|
||||
+ ]);
|
||||
+ // Relationship order and ZIP entry order do not determine part identity.
|
||||
+ for (const entry of Object.values(zip.files)) {
|
||||
+ const match = entry.name.match(/^(.*\/)?_rels\/([^/]+)\.rels$/i);
|
||||
+ if (entry.dir || (!match && partKey(entry.name) !== '_rels/.rels')) continue;
|
||||
+ const owner = match ? `${match[1] || ''}${match[2]}` : '';
|
||||
+ const rels = await this.parseRels([await this._readXmlEntry(entry)]);
|
||||
+ if (!rels) throw new Error(`Invalid relationships: ${entry.name}`);
|
||||
+ for (const rel of rels) {
|
||||
+ rel.Type = rel.Type.replace(/^http:\/\/purl\.oclc\.org\/ooxml\/officeDocument\/relationships\//,
|
||||
+ 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/');
|
||||
+ if (!resolvedTypes.has(rel.Type)) continue;
|
||||
+ if (rel.TargetMode === 'External') throw new Error(`External workbook part: ${rel.Target}`);
|
||||
+ const target = this._resolvePart(owner, rel.Target);
|
||||
+ rel.Target = partNames.get(partKey(target));
|
||||
+ if (!rel.Target) throw new Error(`Missing workbook part: ${target}`);
|
||||
+ const type = partTypes.get(rel.Target);
|
||||
+ if (type && type !== rel.Type) throw new Error(`Conflicting workbook part: ${rel.Target}`);
|
||||
+ partTypes.set(rel.Target, rel.Type);
|
||||
+ }
|
||||
+ relationships.set(partKey(owner), rels);
|
||||
+ }
|
||||
+ model.globalRels = relationships.get('') || [];
|
||||
+ const officeDocuments = model.globalRels.filter(rel => rel.Type === XLSX.RelType.OfficeDocument);
|
||||
+ if (officeDocuments.length !== 1) throw new Error('Expected one workbook relationship');
|
||||
+ model.workbookRels = relationships.get(partKey(officeDocuments[0].Target));
|
||||
+ if (!model.workbookRels) throw new Error('Missing workbook relationships');
|
||||
+ for (const [path, type] of partTypes) {
|
||||
+ if (type === XLSX.RelType.Worksheet) model.worksheetRels[path] = relationships.get(partKey(path));
|
||||
+ }
|
||||
for (const entry of Object.values(zip.files)) {
|
||||
/* eslint-disable no-await-in-loop */
|
||||
if (!entry.dir) {
|
||||
@@ -284,12 +346,9 @@ class XLSX {
|
||||
if (entryName[0] === '/') {
|
||||
entryName = entryName.substr(1);
|
||||
}
|
||||
+ if (!partTypes.has(entryName) && !/\.(xml|rels|vml)$/i.test(entryName) && !entryName.match(/xl\/media\//)) continue;
|
||||
let stream;
|
||||
- if (
|
||||
- entryName.match(/xl\/media\//) ||
|
||||
- // themes are not parsed as stream
|
||||
- entryName.match(/xl\/theme\/([a-zA-Z0-9]+)[.]xml/)
|
||||
- ) {
|
||||
+ if (entryName.match(/xl\/media\//)) {
|
||||
stream = new PassThrough();
|
||||
stream.write(await entry.async('nodebuffer'));
|
||||
} else {
|
||||
@@ -298,50 +357,47 @@ class XLSX {
|
||||
writableObjectMode: true,
|
||||
readableObjectMode: true,
|
||||
});
|
||||
- let content;
|
||||
- // https://www.npmjs.com/package/process
|
||||
- if (process.browser) {
|
||||
- // running in browser, use TextDecoder if possible
|
||||
- content = bufferToString(await entry.async('nodebuffer'));
|
||||
- } else {
|
||||
- // running in node.js
|
||||
- content = await entry.async('string');
|
||||
- }
|
||||
+ const content = await this._readXmlEntry(entry);
|
||||
const chunkSize = 16 * 1024;
|
||||
for (let i = 0; i < content.length; i += chunkSize) {
|
||||
stream.write(content.substring(i, i + chunkSize));
|
||||
}
|
||||
}
|
||||
stream.end();
|
||||
- switch (entryName) {
|
||||
- case '_rels/.rels':
|
||||
- model.globalRels = await this.parseRels(stream);
|
||||
- break;
|
||||
-
|
||||
- case 'xl/workbook.xml': {
|
||||
+ switch (partTypes.get(entryName)) {
|
||||
+ case XLSX.RelType.OfficeDocument: {
|
||||
const workbook = await this.parseWorkbook(stream);
|
||||
model.sheets = workbook.sheets;
|
||||
model.definedNames = workbook.definedNames;
|
||||
model.views = workbook.views;
|
||||
model.properties = workbook.properties;
|
||||
model.calcProperties = workbook.calcProperties;
|
||||
- break;
|
||||
+ continue;
|
||||
}
|
||||
-
|
||||
- case 'xl/_rels/workbook.xml.rels':
|
||||
- model.workbookRels = await this.parseRels(stream);
|
||||
- break;
|
||||
-
|
||||
- case 'xl/sharedStrings.xml':
|
||||
+ case XLSX.RelType.SharedStrings:
|
||||
model.sharedStrings = new SharedStringsXform();
|
||||
await model.sharedStrings.parseStream(stream);
|
||||
- break;
|
||||
-
|
||||
- case 'xl/styles.xml':
|
||||
+ continue;
|
||||
+ case XLSX.RelType.Styles:
|
||||
model.styles = new StylesXform();
|
||||
await model.styles.parseStream(stream);
|
||||
+ continue;
|
||||
+ case XLSX.RelType.Worksheet:
|
||||
+ await this._processWorksheetEntry(stream, model, entryName, options, entryName);
|
||||
+ continue;
|
||||
+ case XLSX.RelType.Comments:
|
||||
+ await this._processCommentEntry(stream, model, entryName);
|
||||
+ continue;
|
||||
+ case XLSX.RelType.Table:
|
||||
+ await this._processTableEntry(stream, model, entryName);
|
||||
+ continue;
|
||||
+ case XLSX.RelType.VmlDrawing:
|
||||
+ await this._processVmlDrawingEntry(stream, model, entryName);
|
||||
+ continue;
|
||||
+ default:
|
||||
break;
|
||||
-
|
||||
+ }
|
||||
+ switch (entryName) {
|
||||
case 'docProps/app.xml': {
|
||||
const appXform = new AppXform();
|
||||
const appProperties = await appXform.parseStream(stream);
|
||||
@@ -358,17 +414,7 @@ class XLSX {
|
||||
}
|
||||
|
||||
default: {
|
||||
- let match = entryName.match(/xl\/worksheets\/sheet(\d+)[.]xml/);
|
||||
- if (match) {
|
||||
- await this._processWorksheetEntry(stream, model, match[1], options, entryName);
|
||||
- break;
|
||||
- }
|
||||
- match = entryName.match(/xl\/worksheets\/_rels\/sheet(\d+)[.]xml.rels/);
|
||||
- if (match) {
|
||||
- await this._processWorksheetRelsEntry(stream, model, match[1]);
|
||||
- break;
|
||||
- }
|
||||
- match = entryName.match(/xl\/theme\/([a-zA-Z0-9]+)[.]xml/);
|
||||
+ let match = entryName.match(/xl\/theme\/([a-zA-Z0-9]+)[.]xml/);
|
||||
if (match) {
|
||||
await this._processThemeEntry(stream, model, match[1]);
|
||||
break;
|
||||
@@ -383,26 +429,11 @@ class XLSX {
|
||||
await this._processDrawingEntry(stream, model, match[1]);
|
||||
break;
|
||||
}
|
||||
- match = entryName.match(/xl\/(comments\d+)[.]xml/);
|
||||
- if (match) {
|
||||
- await this._processCommentEntry(stream, model, match[1]);
|
||||
- break;
|
||||
- }
|
||||
- match = entryName.match(/xl\/tables\/(table\d+)[.]xml/);
|
||||
- if (match) {
|
||||
- await this._processTableEntry(stream, model, match[1]);
|
||||
- break;
|
||||
- }
|
||||
match = entryName.match(/xl\/drawings\/_rels\/([a-zA-Z0-9]+)[.]xml[.]rels/);
|
||||
if (match) {
|
||||
await this._processDrawingRelsEntry(stream, model, match[1]);
|
||||
break;
|
||||
}
|
||||
- match = entryName.match(/xl\/drawings\/(vmlDrawing\d+)[.]vml/);
|
||||
- if (match) {
|
||||
- await this._processVmlDrawingEntry(stream, model, match[1]);
|
||||
- break;
|
||||
- }
|
||||
}
|
||||
}
|
||||
}
|
||||
diff --git a/package.json b/package.json
|
||||
index 7a70a5e13145a917b57806a6998b8d5e04f47acd..8a1c0a87b0140412517ab7a0fa871afdfbe22b6c 100644
|
||||
--- a/package.json
|
||||
+++ b/package.json
|
||||
@@ -16,7 +16,7 @@
|
||||
"node": ">=8.3.0"
|
||||
},
|
||||
"main": "./excel.js",
|
||||
- "browser": "./dist/exceljs.min.js",
|
||||
+ "browser": "./dist/exceljs.js",
|
||||
"types": "./index.d.ts",
|
||||
"files": [
|
||||
"dist",
|
||||
Generated
+3
-2
@@ -15,6 +15,7 @@ patchedDependencies:
|
||||
'@fortune-sheet/core@1.0.4': 488dad2a7648b7d4df31f6bd776b7849fd0fb85be8f1acf268efff758caba3c0
|
||||
'@fortune-sheet/react@1.0.4': c17549e117c8fb99f408497c1e84292c21f25516fb762a32518f91b4d9ab0f04
|
||||
'@yao-pkg/pkg@6.21.0': 75891fc22b277c145cafeab83e41baf4a70f5b8fe6dc0801c6a0b922d7ee88c7
|
||||
exceljs@4.4.0: c1bf8ac8f319dab382f08e3a669b715e691d46c703ac5a4163eb383fbedd1d48
|
||||
node-pty@1.2.0-beta.15: b40ae545608897914bd25fb009c97eeac478c34e8a910298ddcb01b746534bb0
|
||||
|
||||
importers:
|
||||
@@ -5040,7 +5041,7 @@ importers:
|
||||
version: 3.4.11
|
||||
exceljs:
|
||||
specifier: 4.4.0
|
||||
version: 4.4.0
|
||||
version: 4.4.0(patch_hash=c1bf8ac8f319dab382f08e3a669b715e691d46c703ac5a4163eb383fbedd1d48)
|
||||
fast-xml-parser:
|
||||
specifier: 5.11.1
|
||||
version: 5.11.1
|
||||
@@ -25687,7 +25688,7 @@ snapshots:
|
||||
dependencies:
|
||||
eventsource-parser: 3.1.0
|
||||
|
||||
exceljs@4.4.0:
|
||||
exceljs@4.4.0(patch_hash=c1bf8ac8f319dab382f08e3a669b715e691d46c703ac5a4163eb383fbedd1d48):
|
||||
dependencies:
|
||||
archiver: 5.3.2
|
||||
dayjs: 1.11.21
|
||||
|
||||
@@ -100,4 +100,5 @@ patchedDependencies:
|
||||
'@fortune-sheet/core@1.0.4': patches/@fortune-sheet__core@1.0.4.patch
|
||||
'@fortune-sheet/react@1.0.4': patches/@fortune-sheet__react@1.0.4.patch
|
||||
'@yao-pkg/pkg@6.21.0': patches/@yao-pkg__pkg@6.21.0.patch
|
||||
exceljs@4.4.0: patches/exceljs@4.4.0.patch
|
||||
node-pty@1.2.0-beta.15: patches/node-pty@1.2.0-beta.15.patch
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Independent-writer Excel previews
|
||||
|
||||
- comments.xlsx: 数据 opened; Item copied
|
||||
- table.xlsx: 数据 opened; Item copied
|
||||
- prefix.xlsx: 数据 opened; Item copied
|
||||
- part-name.xlsx: 数据 opened; Item copied
|
||||
- relative.xlsx: 数据 opened; Item copied
|
||||
- combined.xlsx: 数据 opened; Item copied
|
||||
- strict.xlsx: 数据 opened; Item copied
|
||||
- case-root.xlsx: 数据 opened; Item copied
|
||||
- case-parts.xlsx: 数据 opened; Item copied
|
||||
- orphan-drawing.xlsx: 数据 opened; Item copied
|
||||
- rich.xlsx: 数据 opened; Item copied
|
||||
- utf16-sheet.xlsx: 数据 opened; Item copied
|
||||
- utf16-metadata.xlsx: 数据 opened; Item copied
|
||||
- workbook-path.xlsx: 数据 opened; Item copied
|
||||
- styles-path.xlsx: 数据 opened; Item copied
|
||||
- strings-path.xlsx: 数据 opened; Item copied
|
||||
- cdata.xlsx: 数据 opened; Item copied
|
||||
- parts-path.xlsx: 数据 opened; Item copied
|
||||
- Relocated workbook: hidden sheet omitted
|
||||
- Relocated workbook notice: This preview does not support charts, images, shapes, conditional formatting in this workbook. Open it in a system application for the full experience.
|
||||
@@ -0,0 +1,9 @@
|
||||
version: 1
|
||||
scenario: excel-opc
|
||||
profile: web
|
||||
composition: web-default
|
||||
recording: authored
|
||||
header:
|
||||
class: web-default
|
||||
session:
|
||||
source: ../lifecycle-chrome/session.v3.jsonl
|
||||
@@ -48,6 +48,7 @@
|
||||
"apps/web/tests/details-session-lifecycle.e2e.ts",
|
||||
"apps/web/tests/sessionless-header.e2e.ts",
|
||||
"apps/web/tests/document-preview.e2e.ts",
|
||||
"apps/web/tests/excel-opc.e2e.ts",
|
||||
"apps/web/tests/office-fixture.ts",
|
||||
"apps/web/tests/sidebar-browser.e2e.ts",
|
||||
"apps/web/tests/plugin-config.e2e.ts",
|
||||
|
||||
Reference in New Issue
Block a user