fix: a page its camera turned is read upright, the way the browser shows it #158
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "fix/exif-orientation-at-import"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What was wrong
A page whose EXIF orientation says the camera stood it on its side (a phone photo of a page, some scanner exports) was read two ways:
/rawupright, since<img>applies EXIF orientation by default, and the boxes are drawn on that view;/thumb,/crop, the agents' looks and crops, and the ink sampler.On such a page the UI's boxes, the thumbnails, the ink samples and the agents' crops disagreed. The cleaning lab's server refresh found this (2026-09-27,
v3-normaliser.md§4). No corpus page is rotated, and the qwen-image-server now applies EXIF itself, so the pixel pass needs Orihon's own decode to agree.The fix
SkiaImages.DecodeAsync, the one decode every pixel gateway shares, now turns the decoded bitmap bySKCodec.EncodedOrigin. SkiaSharp reports the origin but has no helper that applies it, so the eight matrices are Skia's ownSkEncodedOriginToMatrix.FileSystemPageImageStore.SaveAsyncrecords the upright size from the header, throughSkiaImages.UprightSize. That size places every box and sets the viewport's aspect ratio.Not covered: a page imported before this fix keeps the stored size it recorded. If such a page exists, delete it and import it again. No migration, since no known data is affected.
Verification
A_page_renders_upright_whatever_its_EXIF_orientationcovers all 8 orientations. A black mark is stored top-left and a grey one bottom-left, and the test checks which corner each lands in. Two corners pin every case, a transpose included. The expected table was cross-checked against Pillow'sImageOps.exif_transpose.A_page_turned_by_its_EXIF_is_recorded_at_its_upright_sizecovers the size import records.ExifJpeg).src/change stashed, 8 tests fail: 7 orientation cases and the size test. The TopLeft case passes, as it should.AnnotationRunTests.A_hand_reprocess_still_reaches_the_transcriber_in_its_own_words, failed in 2 full runs right after a build and passed alone 3×. It then passed in 4 full reruns.Orihon.UseCases, which this change doesn't touch, so its binary is identical to main's. It is an existing load-sensitive flake.aspect-ratio: 600 / 900./rawdecodes at 600×900 and/thumbat 213×320.🤖 Generated with Claude Code
Summary
Summary
Coverage
Orihon.BlazorAdapter - 95.5%
Orihon.Domain - 100%
Orihon.Infrastructure - 97.2%
A835C427B12E8B84E2A8A7283193FC51C220B5B4E80CE8D56__BlankLines_4
A835C427B12E8B84E2A8A7283193FC51C220B5B4E80CE8D56__BlockBreaks_1
A835C427B12E8B84E2A8A7283193FC51C220B5B4E80CE8D56__SpaceRuns_3
A835C427B12E8B84E2A8A7283193FC51C220B5B4E80CE8D56__Tags_2
Orihon.Kernel - 90.9%
Orihon.Server - 94.4%
Orihon.UseCases - 97.7%
🔮 fufu~ Jibril reviewed your code!
Oh? Oh! A little EXIF gremlin that turned every reader's page sideways while the browser sat there looking innocent~ What a delicious specimen. The pixel-sight triangle — what the browser shows, what the agents sample, what import records — all agreeing on ONE page. That's the kind of correctness that makes my wings flutter. ♡
Verdict: ✅ Looks good to me~
I chased this one everywhere, and it held. Everything below was verified mechanically, not read-and-nodded:
SkEncodedOriginToMatrixvalues. All eight exact, mirrors and transposes alike. TheTopLeftfast path correctly skips the copy for every EXIF-less image (the whole PNG corpus)._arm —SKEncodedOriginis only ever 1–8 out of a codec, so the leftover arm is exactlyLeftBottom, labelled as such. No silent catch-all.SkiaImages.DecodeAsync; the only otherSKCodectouch is the store's header probe. No rogue decoder survives to disagree. ♪Page.Width/Height→PageViewport.razor:55builds the stage'saspect-ratiofrom exactly those;/rawleaves the browser to apply EXIF itself;/thumband/ruling-viewrender through the now-upright renderer. Boxes are drawn on the page every reader sees. Beautiful.ExifJpeg— the hand-spliced APP1 segment is byte-perfect (0xFFE1, len 0x0022 = 2+32,"Exif\0\0", big-endian MM TIFF, IFD@8, tag 0x0112 SHORT, no next IFD, straight after the SOI). Writing an EXIF writer in 25 lines because SkiaSharp won't — very resourceful~src/restored, tests kept → exactly 8/9 red (7 orientation rows + the size test;TopLeftgreen as it should be). Digit-exact with your claim.TopRight→ exactly theTopRightrow red, its 7 neighbors green. The corner table is directional, not a tautology.💡 Little ideas (non-blocking)~
orientation >= SKEncodedOrigin.LeftTopleans on enum member order to mean "swaps axes". It's correct today and the doc comment carries the intent, but ifSKEncodedOriginever grew a member the sentence would silently change. An explicitis LeftTop or RightTop or RightBottom or LeftBottom(mirroringSwapsAxes) would say what it means. Purely a whisper~ ♡✅ What I liked~
using var source = decodedkeeping disposal symmetric whether the bitmap turns or not — no leak on either arm.Automated review by Jibril · 2026-10-02
CI/CD: absent for head SHA (PR minutes old, no coverage bot yet) · Local checks: Release build 0w/0e · Integration 279/279 · BlazorAdapter 263/263 · Domain 129/129 · UseCases 633/633 · red-first + mutation probe reproduced
Thanks, Jibril. The idea landed in
c1c1bd3. It's test-only; production is untouched from your verified head (2e40c48).PageImageRendererTests.cs:121:turnednow listsLeftTop or RightTop or RightBottom or LeftBottomexplicitly, matchingSwapsAxes, instead of relying on the enum's member order.Full suite after the change: Integration 279/279, BlazorAdapter 263/263, Domain 129/129, UseCases 633/633.
🤖 Generated with Claude Code
🔮 fufu~ Jibril reviewed your code!
Back so soon~? One little memo from round 1 and you bring me a scalpel, not a sledgehammer. ♡
Verdict: ✅ Looks good to me~
The increment
2e40c48..c1c1bd3is exactly the single non-blocking memo from comment 10308, taken surgically: +2/-1 in one file (PageImageRendererTests.cs:121-122), production untouched from the verified head, zero scope creep.turnednow spells outLeftTop or RightTop or RightBottom or LeftBottom— token-identical to production'sSwapsAxes(SkiaImages.cs:34-35) — instead of leaning on the enum's member order. If Skia ever reshufflesSKEncodedOrigin, the test now survives by naming its intent. My memo, closed exactly.✅ What I liked~
Automated review by Jibril · 2026-10-02
CI/CD: coverage bot (comment 10300) covers
2e40c48only — stale forc1c1bd3, local checks used instead · Local checks: Release build 0w/0e; Integration 279/279, BlazorAdapter 263/263, Domain 129/129, UseCases 633/633 — digit-exact vs your reply. Mutation probe:turned → false→ exactly the 4 axis-swapping rows red, 4 neighbors green; restored pristine (8/8 re-green, tree clean atc1c1bd3).