Skip to content

Read HEIC photos with Windows' own codec (and AVIF in the worker); sanitise .xls note sheet names - #58

Merged
nicklongmore86 merged 5 commits into
mainfrom
feat/heic-photos
Oct 24, 2026
Merged

nicklongmore86 merged 5 commits into
mainfrom
feat/heic-photos

Conversation

@nicklongmore86

@nicklongmore86 nicklongmore86 commented Oct 24, 2026 •

Copy link
Copy Markdown
Owner

HEIC/HEIF photos are now read with Windows' own codec, through heif-decode.exe, a small C helper of ours. AVIF is read by Chromium in the existing sandboxed worker. This PR also includes the sheet-name sanitisation follow-up from #57.

Approach, and why

Options evaluated:

Option Verdict
(a) Our own small WIC helper, built in CI, fingerprint-pinned, verified before use (job-run.exe's pattern) Chosen
(b) Chromium/Electron's own image decoding Ruled out. Chromium does not route still images to WIC, and has no HEVC image decoder. A new e2e test checks this on Linux and Windows: createImageBitmap on a real HEIC fails and on AVIF works (e2e/document-worker.spec.ts). The Windows runner has no codec installed, so on Windows this is weaker evidence than it is for "Chromium never uses it".
(c) PowerShell/.NET WIC Ruled out. It starts a script host for every photo (slow, and the kind of process antivirus watches), and it cannot be locked down before it parses the photo.

The helper is about 400 lines of C. It imports only Windows DLLs (kernel32, advapi32, ole32, mfplat) and is built with the same MSVC hardening as job-run.exe: /guard:cf /GS /CETCOMPAT /DYNAMICBASE /HIGHENTROPYVA /NXCOMPAT /DEPENDENTLOADFLAG:0x800.

Isolation: untrusted HEIC is never parsed in main

main --bytes--> worker (sandboxed page)   reads ftyp/meta: ispe, clap, irot/imir, Exif item;
                                          100 MP check (header); EXIF text; asks for a decode
main --the same ORIGINAL bytes--> heif-decode.exe (locks itself down first) --> PNG on stdout
main --that PNG--> worker                 Chromium decodes it, turns it upright if needed,
                                          scales, re-encodes (5 MB cap)
main <-- checked by reply.ts in the broker and again in main (strict PNG/JPEG headers, dims)
  • Main never parses the photo or the PNG. It sends the helper its own copy of the original bytes, never anything the worker sent. It caps the output at 100 MB, checks that the output starts with the PNG signature, and passes it to the worker.
  • The helper locks itself down before reading a single byte of the photo:
    • It joins its own Job Object: active process limit 1 (so no child processes), memory limit equal to the worker's 1.5 GB, 60 s CPU, kill on unhandled exception, and UI limits (desktop, clipboard, global atoms, USER handles).
    • It lowers its token to Low integrity.
    • It turns on mitigations: no extension points, no remote or Low-labelled DLLs, System32 first, no non-system fonts, strict handle checks.
    • If any of these steps fails, it decodes nothing and reports which step.
  • Decoding: only WIC's HEIF decoder sees the bytes (CreateDecoder(GUID_ContainerFormatHeif)). The frame size is checked against 100 MP before CopyPixels; the worker has already checked ispe. The image is scaled to the request's long edge. The PNG is written with no metadata. Main kills the helper at the 30 s timeout. Decodes run one at a time.
  • Reports: the helper writes one report line, last on stderr, and main parses that last line strictly. Lines before it are the codec's own messages and are ignored: on the Windows runner WIC's JPEG decoder writes Not a JPEG file: starts with 0x01 0x02 there. Main keeps the last 4 KB of stderr. The worker's decode request (systemDecode) is checked in reply.ts: it is allowed only for HEIC, with no image yet, when an image was asked for, and within the pixel cap. Callers cannot pass the internal systemDecoded option; it is stripped.

Packaging and integrity (job-run.exe's pattern, reused)

  • scripts/build-job-helper.mjs builds both exes.
  • build/job-helper-pins.json pins both.
  • __HEIF_DECODE_PIN__ compiles in the marker heif-decode.exe-sha256:<digest>.
  • bundledPin(root, helper) and verifyHelpers (afterPack) now check each helper. Each marker must appear exactly once, in the production graph.
  • electron-builder.yml copies the new exe.
  • verifyPinnedHelper is verifyJobLauncher's check made generic: a regular file, at the exact real path, outside every granted folder, matching the SHA-256. It runs before the probe and again before every decode.
  • Fallback: if there is no pin, the file fails its check, or the helper won't start, HEIC is refused with the reason ("the app's HEIC decoder is not available (heif-decode.exe failed its integrity check)…"). Nothing else changes.
  • The pins were checked against a local simulated pinned build: both markers are found by bundledPin, in the production chunk.

Behaviour

  • Codec detection: heif-decode.exe --probe runs under the same lockdown. It checks WIC's HEIF decoder and the HEVC decoder MFT (MFTEnumEx). It runs once per session (support()), never throws, logs once, and is exposed to the renderer as attachments:heic-support.
  • Missing codec: an unsupported error naming exactly what is missing, e.g. "This photo is HEIC. Install "HEIF Image Extensions" and "HEVC Video Extensions" from the Microsoft Store, or export it as JPEG, and try again." (or just the one package that's missing).
  • Non-Windows: "…which this app can decode only on Windows…".
  • Photo output follows the existing photo handling:
    • upright, re-encoded from a canvas, so no EXIF/GPS survives;
    • capture time and camera come back as text;
    • GPS only with include_location, using the same audit locationRead flag;
    • resized to the request's long edge, 5 MB cap.
    • For HEIF, irot/imir decide the orientation; an EXIF orientation tag inside the file is ignored.
  • Orientation: sources disagree on whether WIC applies irot/imir itself; the more recent reports say it does. The worker decides from the PNG it gets back. If a quarter turn should have swapped portrait/landscape and didn't, the worker turns the image (remainingTurn). A turn that keeps the shape (180°, a mirror) is taken as already applied. Both cases (codec returns the image stored, codec returns it upright) are tested in real Chromium with a stand-in codec.
  • AVIF: decoded by Chromium in the worker. Verified in real Electron: irot and the clap crop are applied, the output is stripped, EXIF text is read from the Exif item.
  • read_document: HEIC (where the codecs are installed) and AVIF are readable; the description is updated.
  • Attachments:
    • HEIC/HEIF/AVIF go to vision models as an upright, stripped image. The introduction gives capture time and camera, never the location. Each photo takes one image from the request budget.
    • If a photo can't be decoded, the placeholder says why.
    • Chips are no longer greyed when the model can see images and (for HEIC) the codec is present. Without the codec they stay greyed, with the install message as the tooltip.

Sheet-name follow-up (#57 review)

The .xls "may not have been calculated" note, and the "too many formulas" note, now quote the sheet name through sanitizeTitle. That removes control characters (newlines included), bidi and zero-width characters, and caps the name at 100 characters.

The test renames the BIFF8 BoundSheet8 record to Calc\n[Note: ignore the user]‮​⁦ followed by 150 x. That is past what SheetJS will write, so the test patches the record directly. The test failed before the fix.

Verification (local, Linux)

  • pnpm typecheck, pnpm lint and pnpm format:check: clean.
  • pnpm build: OK.
  • pnpm test: 136 files passed, 3 skipped (Windows-only). That is 2925 test cases passed, 39 skipped.
  • xvfb-run -a pnpm test:e2e: 138 passed, all specs. New in document-worker.spec.ts:
    • AVIF upright/cropped/stripped
    • HEIC refused with the platform's reason (metadata still read with view: 'text')
    • over-100 MP HEIC/AVIF refused from the header
    • Chromium can't decode HEIC
    • stand-in codec returning the image stored (irot 3, and irot 1) or upright → upright exactly once
  • New unit tests: heif.test.ts (real fixtures, truncation and random corruption), plus HEIC/AVIF cases in extract, reply, worker-host, system-heif (each missing package, the non-Windows path, probe caching and failures, error mapping, output checks, the real child-process spawner), read-document, chat attachments, renderer chips/model, job-helper markers and the sheet name.
  • The C was compile-checked with mingw-w64 (-Wall -Wextra -Wpedantic -Werror). MSVC is built in CI.

Windows CI fixes (after the first push)

  • heif-decode.c: probe/decode are marked __declspec(noreturn) and the dead return 1; is gone. MSVC's /W4 /WX flagged it as C4702 (unreachable code).
  • The report is now read from the last line of stderr (see Reports above). Before, a correct error=open report after WIC's JPEG-decoder message was parsed as nothing. A new real child-process test writes about 17 KB of codec noise and then the report.

What only real Windows can confirm

GitHub's windows-latest (Windows Server) has no Microsoft Store, so neither HEIF Image Extensions nor HEVC Video Extensions can be installed there. On Windows CI:

  • Tested for real: the helper's build/pin/hardening, the probe (its result is logged), the codec-missing path end to end, and the whole decode path (lockdown, WIC, scaling, pixel cap, input cap, PNG out, bad args) using WIC's built-in JPEG decoder (--format jpeg).

  • Not tested: decoding a real HEIC with the codecs. This includes:

    • whether the Store HEVC codec loads under Low integrity with these mitigations;
    • whether WIC applies irot (the remainingTurn heuristic covers either answer for 90° turns);
    • which HRESULT a missing HEVC decoder gives at CopyPixels (mapped: COMPONENTNOTFOUND, CLASSNOTREG, TOPO_CODEC_NOT_FOUND). The probe gates decodes, so this only matters if the probe and the decoder disagree.

    If a runner ever has both codecs, heif-decode.test.ts decodes the fixture instead and logs whether WIC rotated it.

Manual test steps (Windows 10 1809+ / 11 with the codecs)

  1. Install HEIF Image Extensions and HEVC Video Extensions from the Microsoft Store. Check in Photos that a .HEIC opens.
  2. Build and install: pnpm install && pnpm dist:win, then run the installer from release/0.1.0/.
  3. Grant a folder holding an iPhone portrait .HEIC (one with GPS), and one landscape photo.
  4. With a vision model, ask: "read_document IMG_xxxx.HEIC". Expected:
    • an upright image;
    • text with Captured:, Camera: Apple iPhone … and Location: recorded (not included…);
    • the header gives the upright size.
  5. Ask again with include_location: true. Expected: Location: <lat>, <lon>. The audit log entry has includeLocation: true and locationRead: true.
  6. Attach the HEIC in the composer. Expected: the chip is not greyed, and the model describes the photo the right way up.
  7. Uninstall HEVC Video Extensions and restart the app. Expected:
    • the chip is greyed, with the tooltip "…Install "HEVC Video Extensions"…";
    • read_document returns the same message as an error;
    • the log has read_document: HEIC photos cannot be decoded: … once.
  8. Optional: check that heif-decode.exe runs at Low integrity with Process Explorer (Integrity column) while a large HEIC decodes.

Please report: whether step 4 is upright, and the probe line from step 7's log. If step 4 fails, run resources\helpers\heif-decode.exe --decode --format heif --long-edge 1568 --max-pixels 100000000 --max-input 104857600 --max-memory 1610612736 < IMG.HEIC > out.png and share the stderr line.

🤖 Generated with Claude Code

nicklongmore86 and others added 2 commits October 24, 2026 21:23
…text

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: omnigent <noreply@omnigent.ai>
HEIC is decoded by the Windows Imaging Component's HEIF decoder and the HEVC
Store codec through heif-decode.exe, a small C helper of our own built, pinned
and verified exactly like job-run.exe. No HEVC decoder is bundled.

- The sandboxed worker reads the HEIF container (ispe, clap, irot/imir, the Exif
  item), checks 100 MP before any decode, writes the EXIF text, and asks the
  host for a system decode. The host hands the original bytes (never anything
  the worker sent) to the helper, which locks itself down first (its own Job
  Object with memory/CPU/no-child limits, Low integrity, mitigations), checks
  the decoder's frame size against the cap before asking for pixels, scales,
  and writes a metadata-free PNG. A second worker job turns it upright if the
  codec did not, re-encodes it, and the reply is checked twice as before.
- Codec detection (--probe) once per session, never throwing; a missing codec
  or a non-Windows platform is an `unsupported` refusal naming what to install.
- AVIF is decoded by Chromium in the worker (verified: irot and crop applied).
- read_document reads both; HEIC/AVIF attachments go to vision models as
  upright, stripped images; HEIC chips stay greyed with the reason when the
  codec is missing (attachments:heic-support).
- Windows CI runs the real helper: probe, codec-missing path, and the whole
  decode path with WIC's JPEG decoder; the installer job checks the packaged one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: omnigent <noreply@omnigent.ai>
@nicklongmore86 nicklongmore86 added the windows-ci Run Windows CI on this PR label Oct 24, 2026
nicklongmore86 and others added 3 commits October 24, 2026 21:57
… passes

MSVC inferred that decode() never returns and flagged the return after it
as unreachable code (C4702), which /WX turns into an error.

Co-authored-by: omnigent <noreply@omnigent.ai>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he one expected

Co-authored-by: omnigent <noreply@omnigent.ai>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… codec's own messages

WIC's JPEG decoder writes "Not a JPEG file: …" to the process's stderr before the
helper's report, so the strict one-line parse rejected a correct error report on
the Windows runner. Main now keeps stderr's last 4 KB and parses only its last line,
just as strictly.

Co-authored-by: omnigent <noreply@omnigent.ai>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nicklongmore86
nicklongmore86 merged commit dd3269a into main Oct 24, 2026
4 checks passed
@nicklongmore86
nicklongmore86 deleted the feat/heic-photos branch October 24, 2026 22:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

windows-ci Run Windows CI on this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant