From c47ffd5705ae9fcf3ea713b99debd19e5e52ed11 Mon Sep 17 00:00:00 2001 From: John McLear Date: Sat, 9 May 2026 01:33:50 +0800 Subject: [PATCH] feat(export): native DOCX export via html-to-docx (opt-in) (#7568) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(export): native DOCX export via html-to-docx (opt-in) Addresses #7538. The current DOCX export path shells out to LibreOffice, which means every deployment that wants a Word download either installs soffice (~500 MB) or loses that export. This PR adds a pure-JS alternative: render the HTML via the existing exporthtml pipeline, then feed it to the `html-to-docx` library in-process to produce a valid .docx buffer — no soffice required, no subprocess spawn, no temp file dance for the DOCX case. Behavior: - `settings.nativeDocxExport` (default `false`) gates the new path so existing deployments see zero behavior change. - When enabled, `type === 'docx'` requests skip the LibreOffice branch, run `html-to-docx(html)`, and return the buffer with the `application/vnd.openxmlformats-officedocument.wordprocessingml.document` content-type. - If the native converter throws, the handler falls through to the existing LibreOffice path — so flipping the flag on is safe even on a mixed-installation where soffice is still present as a backstop. - Other export formats (pdf, odt, rtf, txt, html, etherpad) are unchanged. Files: - `src/package.json`: `html-to-docx` dep (pure JS, no binary reqs) - `src/node/handler/ExportHandler.ts`: new DOCX branch gated on the setting, with fall-through on error - `src/node/utils/Settings.ts`, `settings.json.template`, `settings.json.docker`, `doc/docker.md`: wire up the new setting + env var (`NATIVE_DOCX_EXPORT`) - `src/tests/backend/specs/export.ts`: two new tests — asserts the exported buffer is a valid ZIP (PK\x03\x04 signature) and the response carries the correct content-type — both with `settings.soffice = 'false'` to prove the path doesn't need soffice at all. Out of scope for this PR: - Native PDF export (would need a PDF rendering step — separate undertaking, and the issue acknowledges the `pdfkit`/puppeteer size trade-off). Closes #7538 Co-Authored-By: Claude Opus 4.7 (1M context) * test(7538): skip native DOCX test when html-to-docx isn't installed The upgrade-from-latest-release CI job installs deps from the previous release's package.json (before this PR adds html-to-docx) and then git-checkouts this branch's code without re-running pnpm install. Under that one workflow the new test can't find the module and fails on the LibreOffice fallback, masking that the native path actually works in every normal install. Guard the describe block with require.resolve('html-to-docx'); Mocha's this.skip() on before cascades to the sibling its. Regular backend tests (pnpm install against this branch's lockfile) still exercise it. Co-Authored-By: Claude Opus 4.7 (1M context) * docs(7538): spec for soffice-free DOCX/PDF export and DOCX import Captures the agreed scope expansion of PR #7568: replace the flag-gated native DOCX path with a soffice-first selection cascade, add native PDF export via pdfkit + a small htmlparser2-driven walker, and add native DOCX import via mammoth. Also defines a shared HTML sanitizer (stripRemoteImages) used by both export converters to close the SSRF surface that Qodo flagged on the html-to-docx path. The spec drops the nativeDocxExport setting and its env var; with soffice configured, behavior is unchanged, and with soffice null, docx/pdf export and docx import all work in-process. odt/doc/rtf (and pdf import) keep needing soffice and are documented as such. Co-Authored-By: Claude Opus 4.7 (1M context) * docs(7538): implementation plan for native DOCX/PDF and DOCX import Bite-sized TDD task breakdown of the soffice-free export/import work: rebase, deps, sanitizer, PDF walker, mammoth wrapper, ExportHandler cascade, route guard, ImportHandler branch, UI fix, flag rollback, verification + Qodo reply. Co-Authored-By: Claude Opus 4.7 (1M context) * chore(7538): add pdfkit, htmlparser2, mammoth deps Pure-JS, no native binaries: - pdfkit ^0.18.0 (PDF rendering) - htmlparser2 ^12 (SAX parser used by walker + sanitizer) - mammoth ^1.12 (DOCX -> HTML for native import) - @types/pdfkit ^0.17 (dev) Co-Authored-By: Claude Opus 4.7 (1M context) * feat(7538): add stripRemoteImages HTML sanitizer Drops elements pointing at non-data, non-relative URLs to prevent the DOCX/PDF converters from making outbound requests via plugin-modified HTML. Closes Qodo finding #4 against the html-to-docx path; will be wired into both export branches in the cascade refactor. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(7538): native PDF export via pdfkit + htmlparser2 walker Renders pad HTML to a PDF Buffer in-process: headings, paragraphs, lists, links, inline emphasis, data:-URI images. Remote images are explicitly skipped at the walker (defense-in-depth on top of the shared stripRemoteImages sanitizer). PDFs are emitted with compress:false so accessibility/SEO indexers that don't FlateDecode can still extract text. Pads are small enough that the size cost is negligible. Walker is 167 LOC, well under the spec's 500-LOC bail-out threshold for switching to pdfmake+html-to-pdfmake+jsdom. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(7538): native DOCX import via mammoth Wraps mammoth.convertToHtml so a soffice-less Etherpad can ingest .docx files. Images are coerced to data: URIs at the converter boundary so the import pipeline never sees a remote src=. Includes a tiny generated DOCX fixture (heading, paragraph, list) under tests/backend/specs/fixtures/ for both this wrapper test and the upcoming end-to-end import test. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(7538): soffice-first cascade in ExportHandler Replaces the flag-gated DOCX branch with a deterministic dispatch: soffice if configured, native DOCX/PDF otherwise, 5xx on native error. Both native paths run plugin-modified HTML through stripRemoteImages first. Test changes: - existing native DOCX block now sets soffice=null (was 'false', a truthy non-null string that sidestepped the route guard); fixes Qodo finding #3. - new native PDF integration tests assert %PDF- header and application/pdf content-type with soffice=null. - new negative test: with soffice=null, /export/odt still returns the 'not enabled' message. - the legacy 500-on-export-error test now uses /bin/false so it exercises the soffice error path explicitly (the cascade dropped the ad-hoc 'false' string; .doc has no native path so this still works as a soffice error probe). Integration tests for native DOCX/PDF currently fail because the /export route guard still treats both formats as soffice-only; the next commit fixes that. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(7538): allow docx/pdf through export guard without soffice Tightens the no-soffice block to ['odt','doc'] only — formats with no native path. docx and pdf are handed to ExportHandler, which dispatches to the in-process converters. Closes Qodo finding #2. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(7538): native DOCX import path in ImportHandler When soffice is null and the upload is .docx, run mammoth and feed the resulting HTML through setPadHTML. Other office formats (pdf/odt/doc/rtf) are explicitly rejected with uploadFailed instead of silently falling through to the ASCII-only path. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(7538): always show DOCX/PDF export links Native paths (#7538) make DOCX and PDF available regardless of soffice presence, so unconditionally render those links. ODT still gates on exportAvailable. Closes Qodo finding #2 on the UI side. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(7538): drop nativeDocxExport flag Selection is now purely soffice-presence-driven (cascade in ExportHandler). The opt-in setting and its NATIVE_DOCX_EXPORT env var are no longer needed -- soffice configured means soffice path; soffice null means native path (DOCX, PDF, and DOCX import). Reverts the additive surface introduced earlier in this PR. Also updates the SOFFICE doc row to reflect that null no longer means 'plain text and HTML only' -- docx/pdf export and docx import now work natively without soffice. Co-Authored-By: Claude Opus 4.7 (1M context) * test(7538): tighten link annotation assertion CodeQL flagged the loose 'raw.includes("etherpad.org")' as 'incomplete URL substring sanitization' (a false positive in test context, but worth fixing). Match the full /URI (host) form instead -- it's both more accurate (we're verifying the PDF link annotation structure) and CodeQL-clean. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(7538): cleaner DOCX/PDF output + round-trip test coverage DOCX: - New extractBody helper drops / +hello
world +`; + assert.strictEqual(extractBody(html), 'hello
world'); + }); + + it('passes a body-less fragment through unchanged', function () { + const html = '

just a fragment

'; + assert.strictEqual(extractBody(html), html); + }); + + it('drops

kept

'; + const out = extractBody(html); + assert.doesNotMatch(out, /style/); + assert.doesNotMatch(out, /\.x/); + assert.match(out, /kept/); + }); + }); + + describe('wrapLooseLines', function () { + const {wrapLooseLines} = require('../../../node/utils/ExportSanitizeHtml'); + + it('wraps loose text in

', function () { + assert.strictEqual(wrapLooseLines('Hello'), '

Hello

'); + }); + + it('keeps single
as soft break inside one paragraph', function () { + assert.strictEqual(wrapLooseLines('A
B'), '

A
B

'); + }); + + it('splits paragraphs on consecutive
', function () { + // Two
s between content: one paragraph break + one empty + //

marker so the blank pad line survives a DOCX round-trip + // through html-to-docx and mammoth. + assert.strictEqual(wrapLooseLines('A

B'), + '

A

B

'); + }); + + it('emits more empty

markers for longer
runs', function () { + // Three
s = 2 blank pad lines between content. + assert.strictEqual(wrapLooseLines('A


B'), + '

A

B

'); + }); + + it('drops trailing
', function () { + assert.strictEqual(wrapLooseLines('Foo
'), '

Foo

'); + }); + + it('leaves block elements alone', function () { + const html = '
  • x
'; + assert.strictEqual(wrapLooseLines(html), html); + }); + + it('handles realistic etherpad pad HTML', function () { + const out = wrapLooseLines( + 'Welcome!

Body text.
More text.
'); + //

-> blank-line marker between Welcome and Body text; + // single
in the second chunk stays as a soft break; + // trailing
is dropped. + assert.strictEqual(out, + '

Welcome!

Body text.
More text.

'); + }); + }); + + describe('dropEmptyBlocks', function () { + const {dropEmptyBlocks} = require('../../../node/utils/ExportSanitizeHtml'); + + it('drops empty heading blocks', function () { + const out = dropEmptyBlocks( + "

Hi



x"); + assert.strictEqual(out, "

Hi



x"); + }); + + it('drops empty code blocks', function () { + assert.strictEqual(dropEmptyBlocks('x'), 'x'); + assert.strictEqual( + dropEmptyBlocks(' \n\t x'), 'x'); + }); + + it('iterates so nested empties are dropped too', function () { + // inside a
-> div becomes empty -> div drops too. + // (

is preserved on purpose; wrapLooseLines uses it as a + // blank-line marker for DOCX round-trip fidelity.) + const out = dropEmptyBlocks('
after'); + assert.strictEqual(out, 'after'); + }); + + it('does not drop empty

(blank-line marker)', function () { + const out = dropEmptyBlocks('

x

y

'); + assert.strictEqual(out, '

x

y

'); + }); + + it('keeps non-empty blocks unchanged', function () { + const html = '

Hi

body

x = 1'; + assert.strictEqual(dropEmptyBlocks(html), html); + }); + }); + + describe('collapseRedundantBrAfterBlocks', function () { + const {collapseRedundantBrAfterBlocks} = + require('../../../node/utils/ExportSanitizeHtml'); + + it('drops
immediately after a closing

', function () { + assert.strictEqual( + collapseRedundantBrAfterBlocks('

x


y

'), + '

x

y

'); + }); + + it('drops
after closing heading and code tags', function () { + for (const tag of ['h1', 'h2', 'h3', 'code', 'pre', 'div', 'blockquote']) { + assert.strictEqual( + collapseRedundantBrAfterBlocks(`<${tag}>x
`), + `<${tag}>x`, + `expected
collapsed`); + } + }); + + it('keeps a standalone
between text', function () { + const html = 'Hello
World'; + assert.strictEqual(collapseRedundantBrAfterBlocks(html), html); + }); + + it('handles whitespace between and
', function () { + assert.strictEqual( + collapseRedundantBrAfterBlocks('

x

\n
after'), + '

x

after'); + }); + + it('drops only one
, leaving any subsequent ones', function () { + //

after a closing block represents (one redundant + one + // intentional blank-line break). After collapsing the first, the + // second remains. + assert.strictEqual( + collapseRedundantBrAfterBlocks('

x



y

'), + '

x


y

'); + }); + }); + + describe('separateAdjacentHeadingBlocks', function () { + const {separateAdjacentHeadingBlocks} = + require('../../../node/utils/ExportSanitizeHtml'); + + it('inserts
between adjacent

and

', function () { + assert.strictEqual( + separateAdjacentHeadingBlocks('

A

B

'), + '

A


B

'); + }); + + it('inserts
between adjacent blocks', function () { + assert.strictEqual( + separateAdjacentHeadingBlocks('AB'), + 'A
B'); + }); + + it('inserts
after a heading before a

', function () { + assert.strictEqual( + separateAdjacentHeadingBlocks('

A

B

'), + '

A


B

'); + }); + + it('does not change adjacent

elements', function () { + const html = '

A

B

'; + assert.strictEqual(separateAdjacentHeadingBlocks(html), html); + }); + + it('handles three-block round-trip case', function () { + // Mirrors what mammoth produces for a pad with H1 + H2 + Code. + assert.strictEqual( + separateAdjacentHeadingBlocks( + '

Welcome

This pad

Code line

'), + '

Welcome


This pad


Code line

'); + }); + }); + + describe('applyMonospaceToCode', function () { + const {applyMonospaceToCode} = + require('../../../node/utils/ExportSanitizeHtml'); + + it('emits a Courier span for inline ', function () { + // The tag itself is dropped (html-to-docx ignores it and + // also breaks children when they're nested inside it). The + // text becomes a Courier-styled inline span. + const out = applyMonospaceToCode('x = 1'); + assert.strictEqual(out, + `x = 1`); + }); + + it('forwards block-level style to a wrapping

', function () { + // ep_headings2 + ep_align emit `` + // for each "Code"-styled pad line. The alignment must reach + // html-to-docx as a paragraph property, so we move the style + // onto a wrapping

. + const out = applyMonospaceToCode("x"); + assert.match(out, /

/); + assert.match(out, /font-family:'Courier New'/); + }); + + it('emits

wrap for

 regardless of style', function () {
+      // 
 is always block-level.
+      const out = applyMonospaceToCode('
preformatted
'); + assert.match(out, /^

/); + assert.match(out, /<\/p>$/); + assert.match(out, /font-family:'Courier New'/); + }); + + it('handles inline , , as bare spans', function () { + for (const tag of ['tt', 'kbd', 'samp']) { + const out = applyMonospaceToCode(`<${tag}>x`); + assert.strictEqual(out, + `x`, + `expected styled span (no ${tag} wrapper) but got: ${out}`); + } + }); + + it('does not touch unrelated tags', function () { + const html = '

plain

bold'; + assert.strictEqual(applyMonospaceToCode(html), html); + }); + + it('does not wrap
elements in the Courier span', function () { + // Regression: html-to-docx drops content when nested + // inside a styled span OR inside . We split on anchors + // and leave them unstyled. + const out = applyMonospaceToCode( + 'Github: link end'); + // Anchor is preserved as-is (no Courier span around it) + assert.match(out, /link<\/a>/); + // Text before the anchor is wrapped + assert.match(out, /font-family:'Courier New', monospace">Github: <\/span>/); + // Text after the anchor is wrapped + assert.match(out, /font-family:'Courier New', monospace"> end<\/span>/); + // wrapper is dropped + assert.doesNotMatch(out, //); + }); + + it('preserves through html-to-docx round-trip', async function () { + try { require.resolve('html-to-docx'); } + catch { this.skip(); return; } + const htmlToDocx = require('html-to-docx'); + const JSZip = require('jszip'); + const buf: Buffer = await htmlToDocx(applyMonospaceToCode( + '

Github: site

')); + const z = await JSZip.loadAsync(buf); + const xml: string = await z.file('word/document.xml').async('text'); + // Anchor must survive: docx hyperlinks live in + assert.match(xml, / in docx'); + assert.match(xml, /]*>site<\/w:t>/, 'expected link text "site"'); + const rels: string = await z.file('word/_rels/document.xml.rels') + .async('text'); + assert.match(rels, /github\.com\/ether\/etherpad/, + 'expected URL in document.xml.rels'); + }); + }); + + describe('htmlToPdfBuffer', function () { + let htmlToPdfBuffer: (html: string) => Promise; + + before(function () { + try { + require.resolve('pdfkit'); + require.resolve('htmlparser2'); + } catch { + this.skip(); + return; + } + htmlToPdfBuffer = require('../../../node/utils/ExportPdfNative').htmlToPdfBuffer; + }); + + it('produces a buffer starting with %PDF-', async function () { + const buf = await htmlToPdfBuffer('

hello world

'); + assert.ok(Buffer.isBuffer(buf), 'must return Buffer'); + assert.ok(buf.length > 100, `buffer suspiciously small: ${buf.length} bytes`); + assert.strictEqual(buf.slice(0, 5).toString('ascii'), '%PDF-'); + }); + + // pdfkit emits visible text as hex strings inside TJ operators + // (e.g. `Title` -> `<5469746c65>`), and pdfkit splits a single text + // run into multiple chunks at kerning boundaries. Decode every hex + // string we find and concatenate the result so substring matching + // works on the visible text content. + const decodeVisibleText = (raw: string): string => { + const out: string[] = []; + const re = /<([0-9a-fA-F]{2,})>/g; + let m: RegExpExecArray | null; + while ((m = re.exec(raw)) !== null) { + try { + out.push(Buffer.from(m[1], 'hex').toString('latin1')); + } catch { /* not hex; ignore */ } + } + return out.join(''); + }; + + const renderText = async (html: string): Promise => { + const buf = await htmlToPdfBuffer(html); + return buf.toString('latin1'); + }; + + it('renders headings, paragraphs, and lists', async function () { + const raw = await renderText(` +

Title

+

Body paragraph here.

+
  • one
  • two
+
  1. alpha
  2. beta
+ `); + const visible = decodeVisibleText(raw); + assert.ok(visible.includes('Title'), `expected Title in: ${visible}`); + assert.ok(visible.includes('Body paragraph here.'), + `expected paragraph in: ${visible}`); + assert.ok(visible.includes('one'), `expected "one" in: ${visible}`); + assert.ok(visible.includes('two'), `expected "two" in: ${visible}`); + assert.ok(visible.includes('alpha'), `expected "alpha" in: ${visible}`); + assert.ok(visible.includes('beta'), `expected "beta" in: ${visible}`); + }); + + it('emits link annotations for ', async function () { + const raw = await renderText('

site

'); + const visible = decodeVisibleText(raw); + assert.ok(visible.includes('site'), `expected "site" in: ${visible}`); + // URL is stored in a /URI dict as a plain (parenthesized) string. + // Match the full /URI (...) form so we're verifying the PDF link + // annotation structure, not just any occurrence of the host string. + assert.match(raw, /\/URI\s*\(https:\/\/etherpad\.org\)/, + 'expected link target URL in PDF /URI dict'); + }); + + it('embeds data: URI images without throwing', async function () { + const tinyPng = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='; + const buf = await htmlToPdfBuffer(``); + assert.ok(buf.length > 200); + }); + + it('ignores unknown tags rather than crashing', async function () { + const buf = await htmlToPdfBuffer( + '

still works

'); + assert.strictEqual(buf.slice(0, 5).toString('ascii'), '%PDF-'); + }); + + it('does not render head/style/script content', async function () { + const raw = await renderText(` + + SECRET_TITLE + + + +

visible body

+ + `); + const visible = decodeVisibleText(raw); + assert.doesNotMatch(visible, /SECRET_TITLE/); + assert.doesNotMatch(visible, /SECRET_CSS/); + assert.doesNotMatch(visible, /SECRET_JS/); + assert.match(visible, /visible body/); + }); + + it('honors text-align style on block elements', async function () { + // pdfkit emits text-positioning matrices for aligned text. We assert + // the alignment option produced different output than left-aligned + // by checking the x coordinate of the BT block. + const leftRaw = await renderText('

aligned text

'); + const rightRaw = await renderText('

aligned text

'); + const leftX = (leftRaw.match(/1 0 0 1 (\d+(?:\.\d+)?)/) || [])[1]; + const rightX = (rightRaw.match(/1 0 0 1 (\d+(?:\.\d+)?)/) || [])[1]; + assert.ok(leftX, 'expected left x'); + assert.ok(rightX, 'expected right x'); + assert.notStrictEqual(leftX, rightX, + `right-aligned text should sit at a different x than left-aligned (left=${leftX} right=${rightX})`); + }); + + it('uses Courier font inside ', async function () { + const raw = await renderText('

before x = 1 after

'); + // pdfkit references the font in the resource dictionary; Courier + // isn't in the default resources so its first use creates a new + // /Font subtype entry. Look for "Courier" anywhere in the PDF. + assert.match(raw, /Courier/); + }); + + it('uses Courier font inside
', async function () {
+      const raw = await renderText('
preformatted text
'); + assert.match(raw, /Courier/); + }); + + it('honors text-align on (ep_headings2 code lines)', async function () { + const leftRaw = await renderText('x = 1'); + const rightRaw = await renderText("x = 1"); + const leftX = (leftRaw.match(/1 0 0 1 (\d+(?:\.\d+)?)/) || [])[1]; + const rightX = (rightRaw.match(/1 0 0 1 (\d+(?:\.\d+)?)/) || [])[1]; + assert.ok(leftX, 'expected left x'); + assert.ok(rightX, 'expected right x'); + assert.notStrictEqual(leftX, rightX, + `right-aligned should sit at a different x than left-aligned (left=${leftX} right=${rightX})`); + }); + + it('honors text-align on
', async function () {
+      const leftRaw = await renderText('
x = 1
'); + const rightRaw = await renderText("
x = 1
"); + const leftX = (leftRaw.match(/1 0 0 1 (\d+(?:\.\d+)?)/) || [])[1]; + const rightX = (rightRaw.match(/1 0 0 1 (\d+(?:\.\d+)?)/) || [])[1]; + assert.notStrictEqual(leftX, rightX, + `right-aligned
 should sit at a different x than left-aligned (left=${leftX} right=${rightX})`);
+    });
+  });
 });
diff --git a/src/tests/backend/specs/fixtures/sample.docx b/src/tests/backend/specs/fixtures/sample.docx
new file mode 100644
index 0000000000000000000000000000000000000000..9178825d86b20c2bffcd6f26367dbd90872c661a
GIT binary patch
literal 24020
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
W@YZvPMhk_P@$ZkwSO5NPdioDbrMQ#;

literal 0
HcmV?d00001

diff --git a/src/tests/backend/specs/import.ts b/src/tests/backend/specs/import.ts
new file mode 100644
index 000000000..ae1826442
--- /dev/null
+++ b/src/tests/backend/specs/import.ts
@@ -0,0 +1,473 @@
+'use strict';
+
+import {MapArrayType} from '../../../node/types/MapType';
+import path from 'path';
+import os from 'os';
+import {promises as fs} from 'fs';
+
+const assert = require('assert').strict;
+const common = require('../common');
+const padManager = require('../../../node/db/PadManager');
+import settings from '../../../node/utils/Settings';
+
+describe(__filename, function () {
+  const settingsBackup: MapArrayType = {};
+  let agent: any;
+
+  before(async function () {
+    agent = await common.init();
+    settingsBackup.soffice = settings.soffice;
+  });
+
+  after(function () {
+    Object.assign(settings, settingsBackup);
+  });
+
+  describe('docxBufferToHtml (#7538)', function () {
+    let docxBufferToHtml: (b: Buffer) => Promise;
+
+    before(function () {
+      try { require.resolve('mammoth'); }
+      catch { this.skip(); return; }
+      docxBufferToHtml = require('../../../node/utils/ImportDocxNative').docxBufferToHtml;
+    });
+
+    it('converts the sample.docx fixture to HTML', async function () {
+      const buf = await fs.readFile(
+          path.join(__dirname, 'fixtures', 'sample.docx'));
+      const html = await docxBufferToHtml(buf);
+      assert.match(html, /Heading/);
+      assert.match(html, /Paragraph body\./);
+      assert.match(html, /one/);
+      assert.match(html, /two/);
+    });
+
+    it('emits no remote image URLs', async function () {
+      const buf = await fs.readFile(
+          path.join(__dirname, 'fixtures', 'sample.docx'));
+      const html = await docxBufferToHtml(buf);
+      assert.doesNotMatch(html, /]+src="https?:/);
+      assert.doesNotMatch(html, /]+src="\/\//);
+    });
+
+    it('preserves paragraph alignment from ', async function () {
+      // Round through html-to-docx so the input docx has  entries
+      // we can verify mammoth + our workaround surface as text-align.
+      try { require.resolve('html-to-docx'); }
+      catch { this.skip(); return; }
+      const htmlToDocx = require('html-to-docx');
+      const docx: Buffer = await htmlToDocx(
+          '

Right heading

' + + '

Center paragraph

' + + '

Left paragraph

' + + '

Justify paragraph

'); + const html = await docxBufferToHtml(docx); + assert.match(html, /]*style="[^"]*text-align:right/, + `expected right-aligned h1 in: ${html}`); + assert.match(html, /]*style="[^"]*text-align:center/, + `expected center-aligned p in: ${html}`); + assert.match(html, /]*style="[^"]*text-align:justify/, + `expected justify-aligned p in: ${html}`); + // Left-aligned paragraph should NOT carry a redundant style attr + // (we skip "left" because it's the CSS default). + assert.match(html, /

Left paragraph<\/p>/); + }); + }); + + describe('end-to-end DOCX import (#7538)', function () { + before(function () { + try { require.resolve('mammoth'); } + catch { this.skip(); return; } + settings.soffice = null; + }); + + it('imports a docx into a pad without soffice', async function () { + const padId = 'test7538DocxImport'; + try { await padManager.removePad(padId); } catch { /* noop */ } + const fixture = path.join(__dirname, 'fixtures', 'sample.docx'); + const res = await agent + .post(`/p/${padId}/import`) + .attach('file', fixture) + .expect(200); + assert.strictEqual(res.body.code, 0, + `import failed: ${JSON.stringify(res.body)}`); + const pad = await padManager.getPad(padId); + const text = pad.text(); + assert.match(text, /Heading/); + assert.match(text, /Paragraph body/); + assert.match(text, /one/); + assert.match(text, /two/); + }); + + it('rejects odt extension when soffice is null', async function () { + const padId = 'test7538OdtReject'; + try { await padManager.removePad(padId); } catch { /* noop */ } + const fixture = path.join(__dirname, 'fixtures', 'sample.docx'); + const odtPath = path.join(__dirname, 'fixtures', 'sample.odt'); + await fs.copyFile(fixture, odtPath); + try { + const res = await agent + .post(`/p/${padId}/import`) + .attach('file', odtPath); + assert.ok( + res.status >= 400 || res.body.code !== 0, + `expected odt import to fail when soffice is null, got: ${res.status} ${JSON.stringify(res.body)}`); + } finally { + await fs.unlink(odtPath).catch(() => undefined); + } + }); + }); + + describe('DOCX export -> import round-trip (#7538)', function () { + before(function () { + try { + require.resolve('html-to-docx'); + require.resolve('mammoth'); + } catch { + this.skip(); + return; + } + settings.soffice = null; + }); + + // Returns the supertest Test so callers can keep chaining .expect(); + // typing as `any` because supertest's Test isn't re-exported as a type + // we can name here. + const fetchBuffer = (req: any): any => req + .buffer(true) + .parse((resp: any, cb: any) => { + const chunks: Buffer[] = []; + resp.on('data', (c: Buffer) => chunks.push(c)); + resp.on('end', () => cb(null, Buffer.concat(chunks))); + }); + + it('preserves text content through native DOCX round-trip', async function () { + const srcPadId = 'test7538RoundTripSrc'; + const dstPadId = 'test7538RoundTripDst'; + const tmpFile = path.join(os.tmpdir(), `roundtrip-${process.pid}.docx`); + + try { + await padManager.removePad(srcPadId); + await padManager.removePad(dstPadId); + } catch { /* noop */ } + + const srcPad = await padManager.getPad(srcPadId, '\n'); + await srcPad.setText('Line one\nLine two\n\nAfter the blank line\n'); + const srcText = srcPad.text(); + assert.match(srcText, /Line one/); + assert.match(srcText, /After the blank line/); + + const exp = await fetchBuffer(agent.get(`/p/${srcPadId}/export/docx`)) + .expect(200); + const docxBuffer: Buffer = exp.body as Buffer; + assert.strictEqual(docxBuffer.slice(0, 4).toString('latin1'), 'PK\x03\x04'); + await fs.writeFile(tmpFile, docxBuffer); + + try { + const imp = await agent + .post(`/p/${dstPadId}/import`) + .attach('file', tmpFile) + .expect(200); + assert.strictEqual(imp.body.code, 0, + `import failed: ${JSON.stringify(imp.body)}`); + + const dstPad = await padManager.getPad(dstPadId); + const dstText = dstPad.text(); + assert.match(dstText, /Line one/); + assert.match(dstText, /Line two/); + assert.match(dstText, /After the blank line/); + } finally { + await fs.unlink(tmpFile).catch(() => undefined); + } + }); + + // Bidirectional round-trip: export from src pad, import into dst pad, + // export again. Compare exhibit (a) to exhibit (c). For text-based + // formats (txt, etherpad) this is straight byte equality. For HTML + // and DOCX we compare the relevant invariants (line text, paragraph + // count) since whitespace and metadata can differ between exports. + const exportPad = async (padId: string, type: string): Promise => { + const r = await fetchBuffer(agent.get(`/p/${padId}/export/${type}`)) + .expect(200); + return r.body as Buffer; + }; + + const importToPad = async (padId: string, content: Buffer, ext: string) => { + try { await padManager.removePad(padId); } catch { /* noop */ } + const tmp = path.join(os.tmpdir(), + `roundtrip-${process.pid}-${Date.now()}.${ext}`); + await fs.writeFile(tmp, content); + try { + const r = await agent.post(`/p/${padId}/import`) + .attach('file', tmp).expect(200); + assert.strictEqual(r.body.code, 0, + `${ext} import failed: ${JSON.stringify(r.body)}`); + } finally { + await fs.unlink(tmp).catch(() => undefined); + } + }; + + const seedPad = async (padId: string, text: string) => { + try { await padManager.removePad(padId); } catch { /* noop */ } + const pad = await padManager.getPad(padId, '\n'); + await pad.setText(text); + }; + + const SAMPLE_TEXT = 'Line one\nLine two\n\nAfter blank\n'; + + it('a==c round-trip: txt export -> import -> export', async function () { + const src = 'test7538RtTxtSrc'; + const dst = 'test7538RtTxtDst'; + await seedPad(src, SAMPLE_TEXT); + const a = await exportPad(src, 'txt'); + await importToPad(dst, a, 'txt'); + const c = await exportPad(dst, 'txt'); + assert.strictEqual(c.toString('utf8'), a.toString('utf8'), + `txt round-trip drift\nA:${JSON.stringify(a.toString('utf8'))}\nC:${JSON.stringify(c.toString('utf8'))}`); + }); + + it('a==c round-trip: etherpad export -> import -> export', async function () { + const src = 'test7538RtEpadSrc'; + const dst = 'test7538RtEpadDst'; + await seedPad(src, SAMPLE_TEXT); + const a = await exportPad(src, 'etherpad'); + await importToPad(dst, a, 'etherpad'); + const c = await exportPad(dst, 'etherpad'); + // etherpad format is JSON metadata + atext; the surrounding metadata + // (timestamps, ids) differs across pads. Assert the line content + // matches by parsing pad text from both pads. + const srcText = (await padManager.getPad(src)).text(); + const dstText = (await padManager.getPad(dst)).text(); + assert.strictEqual(dstText, srcText, + `etherpad round-trip drift\nsrc:${JSON.stringify(srcText)}\ndst:${JSON.stringify(dstText)}`); + assert.ok(a.length > 0 && c.length > 0, + 'expected non-empty etherpad bodies'); + }); + + it('a==c round-trip: html export -> import -> export', async function () { + const src = 'test7538RtHtmlSrc'; + const dst = 'test7538RtHtmlDst'; + await seedPad(src, SAMPLE_TEXT); + const a = (await exportPad(src, 'html')).toString('utf8'); + await importToPad(dst, Buffer.from(a, 'utf8'), 'html'); + const c = (await exportPad(dst, 'html')).toString('utf8'); + // Strip (skin-versioned hashes), trim trailing whitespace, + // and trim trailing
s — etherpad's setPadHTML appends an empty + //

on import to keep a caret below the last line, which adds + // exactly one trailing newline per round-trip. That's pre-existing + // core behavior, so the meaningful invariant is "content lines + // match" with the trailing newline tolerated. + const bodyOf = (s: string) => + (s.match(/([\s\S]*?)<\/body>/i)?.[1] ?? '') + .replace(/(?:\s*)+$/i, '') + .trim(); + assert.strictEqual(bodyOf(c), bodyOf(a), + `html body drift\nA:${JSON.stringify(bodyOf(a))}\nC:${JSON.stringify(bodyOf(c))}`); + }); + + it('a==c round-trip: docx export -> import -> export (line text)', + async function () { + const src = 'test7538RtDocxSrc'; + const dst = 'test7538RtDocxDst'; + await seedPad(src, SAMPLE_TEXT); + const a = await exportPad(src, 'docx'); + await importToPad(dst, a, 'docx'); + // Compare pad text, not docx bytes -- DOCX includes timestamps + // and pad ID metadata in the document properties so byte equality + // is impossible. Pad text equality is the right invariant. + const srcText = (await padManager.getPad(src)).text(); + const dstText = (await padManager.getPad(dst)).text(); + // setPadHTML appends a trailing newline on import, so dst is + // expected to be src plus one trailing '\n'. + const srcNorm = srcText.replace(/\n+$/, '\n'); + const dstNorm = dstText.replace(/\n+$/, '\n'); + assert.strictEqual(dstNorm, srcNorm, + `docx round-trip drift\nsrc:${JSON.stringify(srcText)}\ndst:${JSON.stringify(dstText)}`); + }); + }); + + describe('HTML import — adjacent headings (#7538)', function () { + before(async function () { + // These tests assume ep_headings2 (or another plugin) registers + // h1/h2/etc. as server-side block elements via + // `ccRegisterBlockElements`. Without that hook, contentcollector + // treats

/

as inline and adjacent ones merge into a + // single pad line — making the assertions below moot. The CI + // backend-tests job runs without plugins installed, so skip + // there. Local dev with ep_headings2 installed exercises these. + const hooks = require('../../../static/js/pluginfw/hooks'); + const ccBlockElems: string[] = ([] as string[]).concat( + ...(hooks.callAll('ccRegisterBlockElements') || [])); + const headingsAreBlocks = ccBlockElems.map((t: string) => t.toLowerCase()) + .includes('h1'); + if (!headingsAreBlocks) { + this.skip(); + return; + } + settings.soffice = null; + }); + + const importHtml = async (padId: string, html: string) => { + try { await padManager.removePad(padId); } catch { /* noop */ } + const tmp = path.join(os.tmpdir(), + `htmlimport-${process.pid}-${Date.now()}.html`); + await fs.writeFile(tmp, html); + try { + const r = await agent.post(`/p/${padId}/import`) + .attach('file', tmp).expect(200); + assert.strictEqual(r.body.code, 0, + `import failed: ${JSON.stringify(r.body)}`); + } finally { + await fs.unlink(tmp).catch(() => undefined); + } + }; + + it('does not introduce a blank line between H1 and H2', async function () { + const padId = 'test7538HtmlH1H2'; + await importHtml(padId, '

A

B

'); + const pad = await padManager.getPad(padId); + const lines = (pad.text().split('\n') as string[]).filter( + (l: string, i: number, arr: string[]) => + // ignore the trailing blank line setPadHTML always appends + !(l === '' && i === arr.length - 1)); + // We want exactly two content lines (A and B), no blank line + // injected between them. + const meaningful = lines.filter((l: string) => l.trim().length > 0); + assert.deepStrictEqual(meaningful.length, 2, + `expected 2 content lines, got: ${JSON.stringify(lines)}`); + const between = lines.slice( + lines.findIndex((l: string) => l.includes('A')) + 1, + lines.findIndex((l: string) => l.includes('B'))); + assert.deepStrictEqual(between, [], + `expected no blank line between A and B, got: ${JSON.stringify(between)}`); + }); + + // Reproduce the realistic export-side shape: H1 + two blank pad lines + // (encoded by ep_align as `



`) + // + H2. The pad should round-trip back to H1, blank, blank, H2 -- not + // gain or lose blank lines. +it('preserves blank-line count between H1 and H2 (realistic shape)', async function () { + const padId = 'test7538HtmlBlankLines'; + const html = + '' + + "

A


" + + "


" + + "


" + + "

B


" + + ''; + await importHtml(padId, html); + const pad = await padManager.getPad(padId); + const lines: string[] = pad.text().split('\n'); + // Drop the trailing-newline appended by setPadHTML on import. + while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop(); + // Expect: A, '', '', B + const aIdx = lines.findIndex((l: string) => l.includes('A')); + const bIdx = lines.findIndex((l: string) => l.includes('B')); + assert.notStrictEqual(aIdx, -1, `expected A: ${JSON.stringify(lines)}`); + assert.notStrictEqual(bIdx, -1, `expected B: ${JSON.stringify(lines)}`); + const blankCount = bIdx - aIdx - 1; + assert.strictEqual(blankCount, 2, + `expected 2 blank lines between A and B, got ${blankCount}: ${JSON.stringify(lines)}`); + }); + }); + + describe('Round-trip integrity: heading-style content (#7538)', function () { + before(function () { + try { + require.resolve('html-to-docx'); + require.resolve('mammoth'); + } catch { + this.skip(); + return; + } + settings.soffice = null; + }); + + const fetchBuffer = (req: any): any => req + .buffer(true) + .parse((resp: any, cb: any) => { + const chunks: Buffer[] = []; + resp.on('data', (c: Buffer) => chunks.push(c)); + resp.on('end', () => cb(null, Buffer.concat(chunks))); + }); + + it('keeps adjacent heading-style blocks on separate lines after round-trip', async function () { + // Regression: ep_headings2 emits

/

/ that aren't in + // contentcollector's default block-element set. Without the + // separateAdjacentHeadingBlocks fix, mammoth's

A

B

+ // would merge into one pad line. + const srcPadId = 'test7538MultiHeading'; + const dstPadId = 'test7538MultiHeadingImport'; + const tmpFile = path.join(os.tmpdir(), `multiheading-${process.pid}.docx`); + + try { + await padManager.removePad(srcPadId); + await padManager.removePad(dstPadId); + } catch { /* noop */ } + + // Drive the import path directly with a hand-crafted DOCX whose + // content is just three adjacent block elements; this is what + // mammoth produces from the round-trip output of ep_headings2's + // pad HTML. + const htmlToDocx = require('html-to-docx'); + const buf: Buffer = await htmlToDocx( + '

Welcome

This pad

Code line

'); + await fs.writeFile(tmpFile, buf); + + try { + await agent.post(`/p/${dstPadId}/import`) + .attach('file', tmpFile) + .expect(200); + const dstPad = await padManager.getPad(dstPadId); + const lines = dstPad.text().split('\n'); + // Each block must land on its own line (lines may carry an + // etherpad heading marker prefix like '*' from ep_headings2). + const findLine = (needle: string) => + lines.findIndex((l: string) => l.includes(needle)); + const iWelcome = findLine('Welcome'); + const iThisPad = findLine('This pad'); + const iCode = findLine('Code line'); + assert.notStrictEqual(iWelcome, -1, + `expected "Welcome" on its own line: ${JSON.stringify(lines)}`); + assert.notStrictEqual(iThisPad, -1, + `expected "This pad" on its own line: ${JSON.stringify(lines)}`); + assert.notStrictEqual(iCode, -1, + `expected "Code line" on its own line: ${JSON.stringify(lines)}`); + assert.ok(iWelcome !== iThisPad && iThisPad !== iCode, + `headings/code merged into the same line: ${JSON.stringify(lines)}`); + } finally { + await fs.unlink(tmpFile).catch(() => undefined); + } + }); + + it('preserves text content through native PDF export (sanity check)', async function () { + // PDF round-trip is one-way (no native PDF import) -- this just + // verifies the exported PDF has the source text in its visible + // content stream, so we know nothing got dropped on export. + const padId = 'test7538PdfSanity'; + try { await padManager.removePad(padId); } catch { /* noop */ } + const pad = await padManager.getPad(padId, '\n'); + await pad.setText('Hello PDF\nSecond line\n'); + + const exp = await fetchBuffer(agent.get(`/p/${padId}/export/pdf`)) + .expect(200); + const pdf: Buffer = exp.body as Buffer; + assert.strictEqual(pdf.slice(0, 5).toString('ascii'), '%PDF-'); + + // pdfkit emits text as hex strings inside TJ ops; concat them and + // search the visible content. + const ascii = pdf.toString('latin1'); + const visible: string[] = []; + const re = /<([0-9a-fA-F]{2,})>/g; + let m: RegExpExecArray | null; + while ((m = re.exec(ascii)) !== null) { + visible.push(Buffer.from(m[1], 'hex').toString('latin1')); + } + const concatenated = visible.join(''); + assert.ok(concatenated.includes('Hello PDF'), + `expected "Hello PDF" in PDF visible content: ${concatenated}`); + assert.ok(concatenated.includes('Second line'), + `expected "Second line" in PDF visible content: ${concatenated}`); + }); + }); +});