diff --git a/.github/workflows/docx-issue-diagnostics-v2.yml b/.github/workflows/docx-issue-diagnostics-v2.yml new file mode 100644 index 00000000..6e156379 --- /dev/null +++ b/.github/workflows/docx-issue-diagnostics-v2.yml @@ -0,0 +1,158 @@ +name: DOCX issue diagnostics v2 + +on: + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + diagnose: + if: github.head_ref == 'feature/docx-layout-155-161' + runs-on: ubuntu-latest + timeout-minutes: 45 + + steps: + - uses: actions/checkout@v7 + - uses: pnpm/action-setup@v6 + with: + version: 11.0.9 + run_install: false + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Download public fixtures + run: | + mkdir -p apps/viewer-demo/public/issue-fixtures + curl --fail --location --retry 3 'https://github.com/user-attachments/files/30333140/Japanese-Template.docx' --output apps/viewer-demo/public/issue-fixtures/issue-155.docx + curl --fail --location --retry 3 'https://github.com/user-attachments/files/30398444/-.W00213.docx' --output apps/viewer-demo/public/issue-fixtures/issue-161.docx + - run: pnpm build + - run: pnpm exec playwright install --with-deps chromium + + - name: Capture current rendering + shell: bash + run: | + mkdir -p /tmp/docx-diagnostics-v2 + cat > .docx-diagnostics-v2.mjs <<'NODE' + import { createReadStream, existsSync, statSync, writeFileSync } from 'node:fs' + import { createServer } from 'node:http' + import { extname, join, normalize, resolve } from 'node:path' + import { chromium } from 'playwright' + + const dist = resolve('apps/viewer-demo/dist') + const entry = join(dist, 'index.html') + const outputDir = '/tmp/docx-diagnostics-v2' + const mime = { + '.css': 'text/css; charset=utf-8', + '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.mjs': 'text/javascript; charset=utf-8', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.wasm': 'application/wasm' + } + const server = createServer((request, response) => { + const pathname = decodeURIComponent(new URL(request.url || '/', 'http://127.0.0.1').pathname) + const candidate = pathname === '/' ? entry : join(dist, normalize(pathname).replace(/^[/\\]+/, '')) + const file = existsSync(candidate) && statSync(candidate).isFile() ? candidate : entry + response.writeHead(200, { 'Content-Type': mime[extname(file).toLowerCase()] || 'application/octet-stream', 'Cache-Control': 'no-store' }) + createReadStream(file).pipe(response) + }) + await new Promise((resolveListen, rejectListen) => { + server.once('error', rejectListen) + server.listen(0, '127.0.0.1', resolveListen) + }) + const address = server.address() + const base = `http://127.0.0.1:${address.port}` + const browser = await chromium.launch({ headless: true }) + const cases = [['155', 'issue-155.docx'], ['161', 'issue-161.docx']] + + try { + for (const [id, filename] of cases) { + const page = await browser.newPage({ viewport: { width: 1600, height: 1100 } }) + const errors = [] + page.on('console', message => { if (message.type() === 'error') errors.push(message.text()) }) + page.on('pageerror', error => errors.push(error.stack || error.message)) + await page.goto(`${base}/?lang=zh&url=${encodeURIComponent(`/issue-fixtures/${filename}`)}`, { waitUntil: 'domcontentloaded', timeout: 120000 }) + await page.waitForSelector('.file-viewer .content.docx-fit-viewer:not(.hidden)', { timeout: 120000 }) + await page.waitForTimeout(4000) + + const data = await page.evaluate(() => { + const root = document.querySelector('.docx-fit-viewer') + const frames = Array.from(root?.querySelectorAll('.docx-page-frame, .docx-flow-frame') || []) + const sections = Array.from(root?.querySelectorAll('section.docx') || []) + const elements = root ? Array.from(root.querySelectorAll('*')) : [] + const leaves = elements.filter(element => !element.children.length && element.textContent?.trim()) + const textNodes = leaves.slice(0, 3000).map(element => { + const rect = element.getBoundingClientRect() + const style = getComputedStyle(element) + return { + text: element.textContent.trim().slice(0, 180), + tag: element.tagName, + className: element.className, + rect: [rect.left, rect.top, rect.width, rect.height], + fontSize: style.fontSize, + lineHeight: style.lineHeight, + fontFamily: style.fontFamily, + position: style.position, + transform: style.transform, + overflow: style.overflow + } + }) + const summary = nodes => nodes.map((node, index) => { + const rect = node.getBoundingClientRect() + const style = getComputedStyle(node) + return { + index, + className: node.className, + rect: [rect.left, rect.top, rect.width, rect.height], + scroll: [node.scrollWidth, node.scrollHeight], + position: style.position, + overflow: style.overflow, + transform: style.transform, + section: node.dataset.docxSection, + sectionNumber: node.dataset.docxSectionNumber + } + }) + return { + rootClassName: root?.className || '', + rootHtmlLength: root?.innerHTML.length || 0, + counts: { + frames: frames.length, + sections: sections.length, + drawings: root?.querySelectorAll('svg,img,canvas').length || 0, + absolute: elements.filter(element => getComputedStyle(element).position === 'absolute').length + }, + frames: summary(frames), + sections: summary(sections), + textNodes + } + }) + data.consoleErrors = errors + writeFileSync(`${outputDir}/issue-${id}-dom.json`, JSON.stringify(data, null, 2)) + writeFileSync(`${outputDir}/issue-${id}-rendered.html`, await page.locator('.docx-fit-viewer').first().evaluate(element => element.outerHTML)) + await page.screenshot({ path: `${outputDir}/issue-${id}-viewport.png` }) + const first = page.locator('.docx-page-frame, .docx-flow-frame').first() + if (await first.count()) await first.screenshot({ path: `${outputDir}/issue-${id}-first-frame.png` }) + await page.close() + } + } finally { + await browser.close() + await new Promise(resolveClose => server.close(resolveClose)) + } + NODE + node .docx-diagnostics-v2.mjs 2>&1 | tee /tmp/docx-diagnostics-v2/render.log + rm .docx-diagnostics-v2.mjs + + - name: Upload diagnostics + uses: actions/upload-artifact@v4 + with: + name: file-viewer-docx-issues-155-161-diagnostics-v2 + path: /tmp/docx-diagnostics-v2 + if-no-files-found: error + retention-days: 2 diff --git a/.github/workflows/docx-issue-diagnostics.yml b/.github/workflows/docx-issue-diagnostics.yml new file mode 100644 index 00000000..42339de1 --- /dev/null +++ b/.github/workflows/docx-issue-diagnostics.yml @@ -0,0 +1,241 @@ +name: DOCX issue diagnostics + +on: + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + diagnose: + if: github.head_ref == 'feature/docx-layout-155-161' + runs-on: ubuntu-latest + timeout-minutes: 45 + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Set up pnpm + uses: pnpm/action-setup@v6 + with: + version: 11.0.9 + run_install: false + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Download public issue fixtures + shell: bash + run: | + set -euo pipefail + mkdir -p apps/viewer-demo/public/issue-fixtures + curl --fail --location --retry 3 \ + 'https://github.com/user-attachments/files/30333140/Japanese-Template.docx' \ + --output apps/viewer-demo/public/issue-fixtures/issue-155-japanese-template.docx + curl --fail --location --retry 3 \ + 'https://github.com/user-attachments/files/30398444/-.W00213.docx' \ + --output apps/viewer-demo/public/issue-fixtures/issue-161-resume.docx + + - name: Build + run: pnpm build + + - name: Install Chromium + run: pnpm exec playwright install --with-deps chromium + + - name: Render diagnostic snapshots + id: render + continue-on-error: true + shell: bash + run: | + set -o pipefail + mkdir -p /tmp/docx-issue-diagnostics + cat > /tmp/render-docx-diagnostics.mjs <<'NODE' + import { createReadStream, existsSync, mkdirSync, statSync, writeFileSync } from 'node:fs' + import { createServer } from 'node:http' + import { extname, join, normalize, resolve } from 'node:path' + import { chromium } from 'playwright' + + const distDir = resolve('apps/viewer-demo/dist') + const outputDir = resolve('/tmp/docx-issue-diagnostics') + mkdirSync(outputDir, { recursive: true }) + const entryPath = join(distDir, 'index.html') + if (!existsSync(entryPath)) throw new Error(`Missing demo build: ${entryPath}`) + const types = { + '.css': 'text/css; charset=utf-8', + '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.mjs': 'text/javascript; charset=utf-8', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.wasm': 'application/wasm' + } + const server = createServer((request, response) => { + const url = new URL(request.url || '/', 'http://127.0.0.1') + const clean = decodeURIComponent(url.pathname) + const candidate = clean === '/' ? entryPath : join(distDir, normalize(clean).replace(/^[/\\]+/, '')) + const file = existsSync(candidate) && statSync(candidate).isFile() ? candidate : entryPath + response.writeHead(200, { + 'Content-Type': types[extname(file).toLowerCase()] || 'application/octet-stream', + 'Cache-Control': 'no-store' + }) + createReadStream(file).pipe(response) + }) + await new Promise((resolveListen, rejectListen) => { + server.once('error', rejectListen) + server.listen(0, '127.0.0.1', resolveListen) + }) + const address = server.address() + const baseUrl = `http://127.0.0.1:${address.port}` + const browser = await chromium.launch({ headless: true }) + const cases = [ + ['155', '/issue-fixtures/issue-155-japanese-template.docx'], + ['161', '/issue-fixtures/issue-161-resume.docx'] + ] + + try { + for (const [id, url] of cases) { + const page = await browser.newPage({ viewport: { width: 1600, height: 1100 }, deviceScaleFactor: 1 }) + const consoleErrors = [] + page.on('console', message => { + if (message.type() === 'error') consoleErrors.push(message.text()) + }) + page.on('pageerror', error => consoleErrors.push(error.stack || error.message)) + + await page.goto(`${baseUrl}/?lang=zh&url=${encodeURIComponent(url)}&diagnostic=issue-${id}`, { + waitUntil: 'domcontentloaded', + timeout: 120000 + }) + await page.waitForSelector('.file-viewer .content.docx-fit-viewer:not(.hidden)', { timeout: 120000 }) + await page.waitForTimeout(5000) + await page.screenshot({ path: join(outputDir, `issue-${id}-viewport.png`), fullPage: false }) + + const firstFrame = page.locator('.docx-page-frame, .docx-flow-frame').first() + if (await firstFrame.count()) { + await firstFrame.screenshot({ path: join(outputDir, `issue-${id}-first-frame.png`) }) + } + + const data = await page.evaluate(() => { + const root = document.querySelector('.docx-fit-viewer') + const wrapper = root?.querySelector('.docx-wrapper') + const frames = Array.from(root?.querySelectorAll('.docx-page-frame, .docx-flow-frame') || []) + const sections = Array.from(root?.querySelectorAll('section.docx') || []) + const articles = Array.from(root?.querySelectorAll('section.docx > article') || []) + const textNodes = [] + const walker = root ? document.createTreeWalker(root, NodeFilter.SHOW_TEXT) : null + let node + while (walker && (node = walker.nextNode())) { + const text = node.textContent?.trim() + const parent = node.parentElement + if (!text || !parent) continue + const rect = parent.getBoundingClientRect() + const style = getComputedStyle(parent) + textNodes.push({ + text: text.slice(0, 160), + tag: parent.tagName, + className: parent.className, + left: rect.left, + top: rect.top, + right: rect.right, + bottom: rect.bottom, + width: rect.width, + height: rect.height, + fontSize: style.fontSize, + lineHeight: style.lineHeight, + fontFamily: style.fontFamily, + position: style.position, + transform: style.transform, + whiteSpace: style.whiteSpace, + verticalAlign: style.verticalAlign, + display: style.display + }) + if (textNodes.length >= 2500) break + } + const elementSummary = elements => elements.map((element, index) => { + const rect = element.getBoundingClientRect() + const style = getComputedStyle(element) + return { + index, + className: element.className, + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height, + scrollWidth: element.scrollWidth, + scrollHeight: element.scrollHeight, + position: style.position, + overflow: style.overflow, + transform: style.transform, + pageWidth: style.getPropertyValue('--docx-page-width'), + pageHeight: style.getPropertyValue('--docx-page-height') + } + }) + const allElements = root ? Array.from(root.querySelectorAll('*')) : [] + return { + href: location.href, + rootClassName: root?.className || '', + rootHtmlLength: root?.innerHTML.length || 0, + wrapper: wrapper ? elementSummary([wrapper])[0] : null, + frames: elementSummary(frames), + sections: elementSummary(sections), + articles: elementSummary(articles), + textNodes, + counts: { + frames: frames.length, + sections: sections.length, + articles: articles.length, + drawings: root?.querySelectorAll('svg, canvas, img').length || 0, + absolute: allElements.filter(element => getComputedStyle(element).position === 'absolute').length, + alternateChoiceMarkers: root?.querySelectorAll('[data-mc-choice], [data-alternate-choice]').length || 0 + } + } + }) + data.consoleErrors = consoleErrors + writeFileSync(join(outputDir, `issue-${id}-dom.json`), JSON.stringify(data, null, 2)) + const html = await page.locator('.docx-fit-viewer').first().evaluate(element => element.outerHTML) + writeFileSync(join(outputDir, `issue-${id}-rendered.html`), html) + await page.close() + } + } finally { + await browser.close() + await new Promise(resolveClose => server.close(resolveClose)) + } + NODE + node /tmp/render-docx-diagnostics.mjs 2>&1 | tee /tmp/docx-issue-diagnostics/render.log + + - name: Copy DOCX engine package + if: always() + shell: bash + run: | + set -euo pipefail + mkdir -p /tmp/docx-issue-diagnostics/engine + engine=$(find node_modules/.pnpm -type d -path '*/node_modules/@file-viewer/docx' -print -quit) + if [ -n "$engine" ]; then + cp -a "$engine"/. /tmp/docx-issue-diagnostics/engine/ + printf '%s\n' "$engine" > /tmp/docx-issue-diagnostics/engine-source-path.txt + else + printf 'DOCX engine package not found\n' > /tmp/docx-issue-diagnostics/engine-copy-error.txt + fi + + - name: Upload diagnostics + if: always() + uses: actions/upload-artifact@v4 + with: + name: file-viewer-docx-issues-155-161-diagnostics + path: /tmp/docx-issue-diagnostics + if-no-files-found: error + retention-days: 2 + compression-level: 6 + + - name: Fail when rendering diagnostics failed + if: steps.render.outcome == 'failure' + run: exit 1 diff --git a/.github/workflows/docx-issue-workspace.yml b/.github/workflows/docx-issue-workspace.yml new file mode 100644 index 00000000..23c58d0e --- /dev/null +++ b/.github/workflows/docx-issue-workspace.yml @@ -0,0 +1,58 @@ +name: DOCX issue workspace + +on: + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + package: + if: github.head_ref == 'feature/docx-layout-155-161' + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + fetch-depth: 1 + + - name: Download public issue fixtures + shell: bash + run: | + set -euo pipefail + mkdir -p issue-fixtures + curl --fail --location --retry 3 \ + 'https://github.com/user-attachments/files/30333140/Japanese-Template.docx' \ + --output issue-fixtures/issue-155-japanese-template.docx + curl --fail --location --retry 3 \ + 'https://github.com/user-attachments/files/30398444/-.W00213.docx' \ + --output issue-fixtures/issue-161-resume.docx + unzip -t issue-fixtures/issue-155-japanese-template.docx + unzip -t issue-fixtures/issue-161-resume.docx + sha256sum issue-fixtures/*.docx > issue-fixtures/SHA256SUMS + + - name: Stage source workspace + shell: bash + run: | + set -euo pipefail + root=/tmp/file-viewer-docx-workspace + rm -rf "$root" + mkdir -p "$root/file-viewer" + rsync -a ./ "$root/file-viewer/" \ + --exclude '.git/' \ + --exclude 'node_modules/' \ + --exclude '**/dist/' \ + --exclude '**/.vitepress/cache/' + printf 'head=%s\n' "$GITHUB_SHA" > "$root/file-viewer/WORKSPACE_SOURCE.txt" + + - name: Upload workspace + uses: actions/upload-artifact@v4 + with: + name: file-viewer-docx-issues-155-161-workspace + path: /tmp/file-viewer-docx-workspace + if-no-files-found: error + retention-days: 2 + compression-level: 6