Skip to content

feat(images)!: refuse HEIC/HEIF input and drop the LGPL libheif dependency - #56

Merged
kklimuk merged 2 commits into
mainfrom
remove-libheif-heic
Nov 6, 2026
Merged

kklimuk merged 2 commits into
mainfrom
remove-libheif-heic

Conversation

@kklimuk

@kklimuk kklimuk commented Nov 6, 2026 •

Copy link
Copy Markdown
Owner

Why

heic-convert pulled in libheif-js (LGPL-3.0), docx-cli's only copyleft dependency. libheif-js also bundles the libde265 HEVC decoder. Keeping it meant:

  • LGPL-3.0 obligations. Every binary had to ship LGPL-3.0.txt, an acknowledgement, and the right to relink with a modified libheif.
  • HEVC patent-pool exposure. We were redistributing an HEVC decoder.

Swapping libraries doesn't help, because no permissively licensed HEVC decoder exists. HEIC demand looks low, so this PR drops the transcode, and docx-cli's own dependency tree is now entirely permissive.

Behavior change (breaking for anyone inserting HEIC)

HEIC is still detected: by extension, by image/heic/image/heif MIME, or by an ftyp HEIF brand in the header. The header check means a .jpg that's really HEIC is still caught. Word can't display HEIC, so embedding one would ship a broken image. Detected HEIC used to be transcoded to JPEG. Now it's refused with IMAGE_SOURCE (exit 1, nothing written) and a hint that gives the conversion command:

$ docx images add demo.docx --after p0 --image sample.heic
{"code":"IMAGE_SOURCE","error":"HEIC/HEIF images are not supported (.heic input): Word can't display them. Convert to JPEG or PNG first.","hint":"Convert it, then insert the converted file. macOS: `sips -s format jpeg IN.heic --out OUT.jpg`. Linux: `heif-convert IN.heic OUT.jpg` (libheif-examples) or ImageMagick `magick IN.heic OUT.jpg`. Otherwise, open it in an image editor and export as JPEG or PNG."}
exit=1   (file bytes unchanged)

IMAGE_SOURCE is the code the existing unsupported-format refusals in source.ts already use. Every ingress goes through the same refusal, and each one is tested:

  • images add from a path, a data:image/heic URI, or HEIC bytes behind a .jpg label
  • markdown ![](x.heic) in insert
  • markdown ![](x.heic) in create --from
  • images replace --with, with both a .heic file and a mislabeled .jpg

ImageSourceError gained an optional hint, which the insert, markdown-import and images-replace error paths now pass along. images replace vets its --with extension before loading the file, so it calls the same heicExtensionRefusal instead of its generic "Unsupported replacement image type" usage error. I ran the hint's sips command on the test asset and inserted the result: exit 0, 96×64 JPEG.

License cleanup

  • Removed heic-convert and @types/heic-convert. libheif-js, heic-decode and heic-convert's private pngjs@6 are gone from bun.lock.
  • Deleted LGPL-3.0.txt. Removed the libheif section and the LGPL language from NOTICES; every other attribution is unchanged.
  • Updated the package.json files list, the release.yml staging step and its comment, README, SECURITY.md, REFERENCE.md, CONTRIBUTING.md (Stack section and the project tree), src/core/image/CLAUDE.md, src/core/render/CLAUDE.md, and the images add --help text.

License audit

I walked the full production dependency closure from package.json dependencies (102 packages) and read each package.json license field:

  • 96 MIT, 3 BSD-3-Clause (diff, highlight.js, jpeg-js), 1 ISC (inherits)
  • pako: MIT AND Zlib
  • jszip: MIT OR GPL-3.0-or-later, and NOTICES already elects MIT

No GPL, LGPL, AGPL or MPL remains. libxml2-wasm (MIT, libxml2 is MIT) and @hyzyla/pdfium (MIT wrapper; PDFium under BSD-3 or Apache-2.0) are fine. strings on the new binary finds no libheif or libde265 code; the only match is the hint text.

Two gaps this audit turned up (the Bun runtime's LGPL parts, and missing NOTICES attributions) are now fixed in later commits; see "Added: attribution and create atomicity" below.

Size

artifact before after delta
dist/docx (darwin-arm64) 100,340,226 B 98,738,562 B −1,601,664 B (−1.5 MiB, −1.6%)
dist/index.js 6,708,959 B 5,131,372 B −1,577,587 B (−23.5%)

Out of scope

  • Falling back to an OS converter (sips, heif-convert, ImageMagick). That would bend the invariant that only docx render needs an external application. It's a possible follow-up, and the hint tells users how to run the conversion themselves.
  • A doc that already embeds a HEIC media part. Pasting its read image reference (![](<sha256>.heic)) back in now fails the hash-reuse path. The old transcode only supplied the dimensions there. Such docs should be vanishingly rare, since Word can't display them.

Also fixed: Windows paths in inline markdown images (second commit)

The new HEIC test for insert failed on the Windows runner with exit 0. The cause is a real bug that predates this PR. Inline --markdown (and the positional content edit/insert fold into it) decodes a typed \n/\t/\r into real whitespace, and that decode also ran inside link and image destinations. A destination can't contain whitespace, so on Windows ![Logo](C:\Users\me\tests\logo.png) lost its image when \t became a TAB. insert/edit then wrote the syntax as literal text and exited 0. That happened for every image format, not just HEIC. create --from passed because it reads markdown from a file, which is never decoded.

The new decodeInlineMarkdownEscapes leaves each ](dest destination verbatim and decodes everything else as before. insert, edit and footnotes/endnotes add+edit now use it for inline markdown. --text and the find/replace positionals keep decodeInlineEscapes. The regression tests use a literal backslash path, so they fail without the fix on every OS, not only Windows. A nested-directory native-path embed covers the real Windows case. The root CLAUDE.md invariant records the carve-out.

Still out of scope: --text and the find/replace positionals still decode a typed \t inside a Windows path in plain prose (--text "saved to C:\temp"). That's the documented trade-off of the inline decode, and --text-file is the verbatim channel.

Added: attribution and create atomicity (commits 3–5)

docs(notices): attribute every bundled package and guard against drift

  • THIRD-PARTY-LICENSES (new, generated by bun run licenses → scripts/third-party-licenses.ts). It walks the production dependency closure from the installed node_modules and reproduces each package's license text. That's 102 packages, sorted, with no versions so a patch bump doesn't churn the file. The generator throws on any license outside a permissive allowlist (MIT, ISC, BSD-2/3, Apache-2.0, 0BSD, MIT AND Zlib, MIT OR GPL-3.0-or-later). It also throws on a required dependency it can't resolve, on a package with no license text it can find, and on two installed copies of one package with different texts. Three packages ship no LICENSE file: remark-math and @nodable/entities use their README's MIT grant, and isarray gets the standard MIT terms under its package.json author, labeled as such.
  • NOTICES:
    • Adds diff (jsdiff) with its BSD-3-Clause text verbatim.
    • Adds libxml2-wasm (MIT), plus the libxml2 C library it compiles in, which is version 2.15.1 from the fork commit its submodule pins. That section reproduces libxml2's Copyright file and the separate notices in dict.c, list.c and timsort.h.
    • Adds pako's zlib-port notice, and lists the license elections (jszip → MIT).
    • Points at THIRD-PARTY-LICENSES for every other MIT/ISC package.
  • Drift tests (tests/cli/installer.test.ts, offline). The first requires the committed file to equal a fresh generation. The second requires NOTICES to have a line-start entry for every production package that isn't plain MIT/ISC. The third requires the npm files list and the release staging step to both carry LICENSE, NOTICES and THIRD-PARTY-LICENSES. I checked that they fail on a stale file or a missing entry.

docs(notices): state that the binaries embed the Bun runtime and its LGPL parts

  • A new NOTICES section states the facts:
    • bun build --compile copies the Bun runtime into each standalone binary.
    • Bun is MIT, and it statically links JavaScriptCore/WebKit, which Bun's licensing page (https://bun.com/docs/project/licensing) describes as LGPL-2. It also links tinycc (LGPL-2.1); strings dist/docx shows both.
    • The section gives where the corresponding source lives (oven-sh/bun, oven-sh/WebKit, oven-sh/tinycc) and how to rebuild docx-cli against a modified runtime.
  • LGPL-2.0.txt (WebKit's Source/JavaScriptCore/COPYING.LIB) and LGPL-2.1.txt (tinycc's COPYING) ship verbatim with every release binary via the release staging step. They're binaries only: the npm package (dist/index.js) embeds no Bun.
  • README and SECURITY state this as fact and don't draw legal conclusions. The drift test also asserts that both texts exist, are named in NOTICES and are staged.

fix(create): build the document in memory and write it once

  • create used to save the blank package and then reopen it for --from/--text-file, page geometry and headers/footers. So any failure (an unreadable source, a refused image, a bad footnote) left a blank .docx behind, and under --force it had already overwritten the old file.
  • Now every step mutates one in-memory Document (Document.fromPackage over the unsaved Pkg), and save() runs once at the end through Pkg's temp-file-and-rename. A failure writes nothing, and a --force target stays byte-identical.
  • --dry-run runs the same build and skips only the save, so it fails where the real run would. Its ack now reports blocks.
  • Tests cover:
    • a failing import → no file
    • an unreadable --from/--text-file → no file
    • a failing --force → bytes unchanged
    • --dry-run, both failing and succeeding → no file
    • a full success with markdown + landscape + footer
  • The HEIC create --from test now asserts that no file exists.

Follow-ups, not changed here:

  • The release workflow's setup-bun installs the latest Bun, so the embedded runtime version isn't pinned or recorded. Pinning it would make NOTICES' source pointer exact.
  • The ECMA-376 XSDs under src/core/raw/schemas carry no license headers.
  • Bun's other statically linked permissive libraries are covered by a reference to Bun's own LICENSE.md, not reproduced one by one.

Checks

bun run check (biome + knip + tsc) passes, and the pre-commit full suite gives 2137 pass / 0 fail. bun run build:binary succeeds (98,738,562 B), and a smoke test of --version, create and read on the binary passes. No version bump, tag or release.

🤖 Generated with Claude Code

kklimuk and others added 2 commits November 6, 2026 09:35
…dency

heic-convert pulled in libheif-js (LGPL-3.0, bundling the libde265 HEVC
decoder), the project's only copyleft dependency. It obliged us to ship
LGPL-3.0.txt and relinking rights with every binary, and bundling an HEVC
decoder carries patent-pool exposure. No permissively licensed HEVC decoder
exists, so swapping libraries wouldn't help; HEIC demand is low, so we drop
the transcode instead.

HEIC is still DETECTED (extension, image/heic|heif MIME, or an ftyp HEIF
brand in the header, so a mislabeled .jpg is caught): Word can't display it,
so embedding it would ship a broken image. Every image ingress (images add
from a path, data: URI, or http(s) URL; markdown images in insert/edit/create;
images replace) now fails IMAGE_SOURCE (exit 1, nothing written) with a hint
giving the conversion command: sips on macOS, heif-convert or ImageMagick on
Linux, else export as JPEG/PNG. ImageSourceError gained an optional hint that
the insert, markdown-import, and images-replace error paths now forward.

- Remove heic-convert + @types/heic-convert; libheif-js and heic-decode leave
  the lockfile. Compiled binary shrinks ~1.5 MiB, dist/index.js ~1.5 MiB.
- Delete LGPL-3.0.txt; drop the libheif section and LGPL language from
  NOTICES, the npm files list, the release staging step, and the docs.

BREAKING CHANGE: inserting or replacing with a HEIC/HEIF image is now
refused instead of transcoded to JPEG. Convert the file first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Inline --markdown (and the positional content edit/insert fold into it)
decodes a typed \n / \t / \r into real whitespace. A link or image
destination can't contain whitespace, so decoding one there only ever broke
it. Windows paths are full of these sequences: in
`![Logo](C:\Users\me\tests\logo.png)` the `\t` became a TAB, GFM stopped
seeing an image, and insert/edit wrote the syntax as literal text at exit 0.
The new HEIC refusal test caught it on the Windows CI runner, whose fixture
path runs through D:\a\docx-cli\docx-cli\tests\...

decodeInlineMarkdownEscapes leaves every `](dest` destination verbatim and
decodes the rest (label, title, surrounding prose) as before. insert, edit,
and footnotes/endnotes add+edit use it for inline markdown; --text and the
find/replace positionals keep decodeInlineEscapes. Regression tests use a
literal backslash path, so they fail without the fix on every OS, plus a
native nested-path embed that is the real Windows case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kklimuk
kklimuk merged commit 4f61487 into main Nov 6, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant