MedMe 医我 · 我们在解决的问题MedMe · Problems We're Solving

← 回主页← Home

不按流水账写。一张卡片一个难题,底下是我们一步步啃它的过程 —— 日期是代码合并进主干的那天,做砸的也写。 Not a diary. Each card is one real problem; underneath is how it got solved, step by step. Dates are when the code merged. The things we got wrong are in here too.

用户在意的:这些东西会不会泄露、会不会丢

把最私密的一批数据,放在一个没有服务器的地方

病历大概是普通人手里最敏感的数据。我们的选择是干脆不建存它的服务器 —— 没有账号、没有云端库,东西就在你自己的设备上。这样一来,就不存在一个装着所有人病历、可以被一次性拖走的库;代价是数据的完整、备份、损坏恢复,全得我们自己在本地做对。

  • 2026-07-13
    删除也是一条记录。删掉一份文档,不是把它从库里抹掉,而是追加一条「已删除」事件 —— 保险箱是只往后追加的流水账,任何一步都能追溯。#118
  • 2026-07-12
    缓存坏了不该等于数据没了。为了查得快,我们会从流水账里生成一份派生的数据库。以前这份派生库一损坏,开库直接崩。现在遇到损坏就丢掉它、从流水账重建 —— 真正的底稿只有一份,就是那本流水账。#68
  • 2026-07-11
    三天里连着合了十几个安全修复。给流水账加防篡改校验、读取时校验文件内容指纹、把桌面的 DICOM 解码放到单独进程里关起来跑 —— 阅片是桌面的活儿,手机端不做这件事,也就不带那批 C/C++ 编解码器、拿掉分享查看器里危险的内联脚本、不再信任前端传来的文件路径,以及 —— 如实告诉用户「系统云同步不是端到端加密的」,而不是含糊带过。#46 · #39 · #38 · #44 · #40 · #48
  • 2026-07-10
    先把入口和出口都收紧。导入、落库、进程通信、分享导出,四处一起加固。#26

需要说明白的一点:「没有服务器」不等于「绝对安全」。设备丢了、系统自带的云备份被别人拿到,这些风险依然在,所以我们才在 #39 里把云同步不是端到端加密这件事直说。分享给医生的那个文件是端到端加密的(AES-256-GCM):它是一个自带解密逻辑的独立 HTML,解密全在对方浏览器里完成,口令不会传给我们。两点如实交代:我们确实托管着一个查看器页面(medmenow.com 上那份)。密文可能经过它,明文永远不会 —— 密钥只在 # 之后,按规范不随请求上行;还有,那串「口令」就是密钥本身的编码,没有再派生一层,所以文件和口令一定要分两个渠道发

What you actually care about: can this leak, and can I lose it?

Putting your most private data somewhere with no server behind it

Medical records may be the most sensitive data an ordinary person holds. Our choice was to not build a server that stores them at all — no accounts, no cloud database, everything sits on your own device. That means there is no single database holding everyone's records for someone to walk off with; the price is that integrity, backup and corruption recovery are all on us to get right locally.

  • 2026-07-13
    Deleting is also a record. Removing a document doesn't erase it from a table — it appends a "deleted" event. The vault is an append-only ledger, so every step stays traceable. #118
  • 2026-07-12
    A corrupt cache shouldn't mean lost data. For fast queries we derive a database from the ledger. Corrupting that derived copy used to crash the vault on open; now we throw it away and rebuild from the ledger. There is only one source of truth, and it's the ledger. #68
  • 2026-07-11
    A dozen-plus security fixes landed over three days. Tamper-evident checks on the ledger, content-hash verification on read, desktop DICOM decoding moved into a separate process — reading scans is a desktop job, so the phone doesn't do it and doesn't ship those C/C++ codecs, dangerous inline scripts removed from the share viewer, no more trusting file paths handed over by the frontend — and telling users plainly that OS cloud sync is not end-to-end encrypted instead of glossing over it. #46 · #39 · #38 · #44 · #40 · #48
  • 2026-07-10
    Tighten both the entrance and the exit. Ingest, materialization, inter-process calls and share/export, hardened together. #26

One thing to be straight about: "local-first" is not the same as "perfectly safe". A lost device, or someone getting at your OS cloud backup, are still real risks — which is why #39 says outright that cloud sync isn't end-to-end encrypted. The file you share with a doctor is end-to-end encrypted (AES-256-GCM): a self-contained HTML that decrypts itself in the recipient's browser, with the passphrase never sent to us. Two things stated plainly: we do host a viewer page (at medmenow.com) — ciphertext may pass through it, plaintext never does, since the key stays after the # and is never sent upstream; and that "passphrase" is the encoded key itself, with no further derivation, so send the file and the passphrase through two different channels.

用户在意的:为什么我等的那个版本还没出

先做手机,桌面往后排 —— 以及为什么桌面还是得做

病历是在诊室、在检查科门口、在缴费窗口排队时产生的,那个场景里你手上只有手机。手机几乎人人都有 —— 我们判断它是绝大多数人的第一选择,所以把它排在最前面。桌面版不是不做,而是定位不同 —— 它是进阶的个人数据查看器:大屏翻片子、比对多年趋势、批量整理。用得上它的人更少,但用得上的时候,手机屏幕替代不了。

  • 2026-07-13
    把旧的那套手机端整个删掉。移动端原本和桌面一样走 Tauri,后来用 Flutter 重写。两套并存只会让人搞不清哪套是真的,所以旧的直接删干净。#113
  • 2026-07-13
    一次撞墙,和撞完之后的绕行。iCloud 同步第一版写完就是链接不上——底层桥在 Flutter 的框架结构下过不去。我们没有硬扛,先把这个功能撤回,再换一条平台标准的通道重做,这才走通。撤回那次也留在记录里,因为它才是当时真实发生的事。#105 · #106
  • 2026-07-12
    Linux 桌面构建先停掉。三个桌面平台一起维护,分摊掉的是手机端的时间。所以只保留了 Windows 和 macOS 的构建流水线,Linux 的先停掉。停就明说停,不挂着一个不会更新的下载链接。#77
  • 2026-07-12
    换成 Flutter,但内核不动。结构化、加密、事件溯源、保险箱格式这些核心都在一份 Rust 代码里,手机和桌面共用;换的只是界面层。识别当时不在其中 —— 那部分用的是各平台系统自带的能力;后来手机端的识别也搬进了这份共用的 Rust 代码(见「让手机读准一张拍歪的化验单」那张卡片)。所以「重写手机端」没有把前面几个月的东西推倒重来。#89 · #93

代价也写清楚。手机优先的直接后果就是桌面端排在后面 —— 桌面版目前暂缓、没有公开安装包,Linux 的构建也停着。这是排序换来的,不是忘了。

What you actually care about: why isn't the version I want out yet?

Phone first, desktop later — and why desktop still has to happen

Medical records get created in the exam room, outside the imaging department, in the queue at the payment window. In those moments the only thing in your hand is a phone. Almost everyone has one, and we're betting it's the first choice for most people, so that's where we started. Desktop isn't cancelled — it plays a different role: the power-user's data viewer. Big screen for scans, multi-year trends side by side, bulk cleanup. Fewer people need that, but when they do, a phone screen won't substitute.

  • 2026-07-13
    Deleted the old mobile app outright. Mobile originally ran on Tauri like the desktop, then got rewritten in Flutter. Keeping both around only makes it unclear which one is real, so the old one is gone. #113
  • 2026-07-13
    One wall we hit, and the way around it. The first iCloud sync implementation simply wouldn't link — the low-level bridge didn't survive Flutter's framework structure. Rather than force it, we pulled the feature back out and rebuilt it over a standard platform channel, which worked. The revert stays in the record, because that's what actually happened. #105 · #106
  • 2026-07-12
    Paused Linux desktop builds. Maintaining three desktop platforms takes its time out of mobile, so we kept the Windows and macOS build pipelines and paused the Linux one. When something is paused we say so, rather than leaving up a download link that never updates. #77
  • 2026-07-12
    Switched to Flutter without touching the core. Structuring, encryption, event sourcing and the vault format all live in one Rust codebase shared by phone and desktop; only the UI layer changed. Recognition was the exception at the time — it ran on each platform's own OCR; it has since moved into that shared Rust code on mobile as well (see "Getting a phone to read a crooked photo of a lab report"). So "rewriting the mobile app" didn't throw away the previous months of work. #89 · #93

The cost, stated plainly. Putting mobile first means desktop comes later — it is paused for now with no public installer, and the Linux build stays paused too. That's what the ordering costs, and it's a choice, not an oversight.

用户在意的:我拍的照片,它能读对吗

让手机读准一张拍歪的化验单

MedMe 的识别和整理都在你自己的设备上完成,照片和 PDF 不会被传到我们这儿 —— 我们也没有存它们的服务器。代价是没有一套现成的引擎可以直接拿来用:每个系统只肯借你它自己那套 OCR,好用程度还不一样。后来我们在手机上把系统引擎换掉、自己带一套进去,起因很具体 —— 它把化验单上的数字读错了,下面那条写了是哪几个数。这张卡片记的就是我们怎么在五个平台上一步步把这条路拼出来,包括中途推翻自己的那次。

  • 2026-07-24
    安卓也换成 PP-OCRv5,和 iOS 同一个引擎、同一套模型。原本安卓走 ML Kit。换过去之后,两台手机跑的是同一份 Rust 代码、同一批模型文件,以后修一处两端都受益 —— 后面那张「一张照片会在哪几个环节坏掉」的卡片,能一处改动两端落地,靠的就是这一步。安卓这边还得把 libc++_shared.so 一起打进包里,不然 PP 在真机上会崩。b7cc3e9 · 5ca2910
  • 2026-07-22
    iOS 从 Apple Vision 改用 PP-OCRv5 —— 推翻的是下面 7 月 13 日我们自己写的那条。真实的中文化验单照片上,Apple Vision 把数字读少了一位:红细胞计数 4.35 读成 1.35、红细胞平均体积 90.1 读成 50.1、红细胞压积 39.2 读成 19.2、中性粒细胞百分比 43.60 读成 4.60。同一批图,PP-OCRv5 读出来的是原值。少一位数,正常就成了异常,这种错不能留在病历里。代价我们也写进了决策文档:iOS 安装包从 59MB 涨到 88MB(约 +29MB),CPU 推理比 Vision 走神经引擎慢 2–3 倍。模型(检测 + 识别 + 字典,约 20MB)用 include_bytes! 编进 Rust 静态库,第一次用到时落进沙盒 —— iOS 沙盒里没有可写的模型目录,所以联网下载那条路在手机上是关掉的。505c23c
  • 2026-07-22
    把 PP-OCRv5 链进 iOS 的那块硬骨头。pyke 预编译的 libonnxruntime.a 里有 28 个自相冲突的重复对象。平时按需链接没事 —— 链接器每个符号取第一个定义。但 cargokit 默认给整个 Rust 静态库上 -force_load(为了不让 Dart 运行时靠 dlsym 找的入口被死代码消除掉),-force_load 会把每个对象都拉进来,于是 757 个重复符号;删掉任意一份,又缺符号。这个库没法整库 force_load。改法是换个思路保住入口:不再 force_load,让 onnxruntime 按需链接,再用 -Wl,-u 把桥接运行时那 14 个固定符号钉成链接根 —— 单个 dispatcher 静态引用了所有 wire 函数,保住它,整条闭包(wire → ocr → ort → onnxruntime)就会被按需拉进来。另外补 -lc++ -framework CoreML:ort 的构建脚本发出的链接指令只在 cargo 自己链接时算数,Xcode 做最终链接时传不过去。505c23c
  • 2026-07-20
    扫描版 PDF:改错两次才改对。手机上把没有文字层的 PDF 渲染成图再识别,渲染倍数原本写死 page.width * 2。我们把 pdfx 的 width 当成了 point,据此换算出 144 DPI,觉得低于中文 OCR 通行的 300 DPI 下限,于是提到 4.17 倍。查一眼文档就知道错了:pdfx 写明 page.width像素,不是 point。放大倍数远超预期,render() 在真机上返回 null,而那行 if (img == null) continue; 一声不吭地跳过整页 —— 问题从「识别不准」变成「一个字都没有」。第二次改法不再赌一个常量:渲染倍数逐级降档 3× → 2× → 1.5×,第一个渲得出来的就用,每一档失败都打日志。顺手补了白底 —— render() 出来的 PNG 默认透明背景,黑字压在透明底上,被 OCR 加载时可能合成成黑底。当时日志里那两句留着:算术没错,前提是错的,而正确的算术让错误的前提显得可信;静默失败是放大器#173 · #175
  • 2026-07-18
    手机能读扫描版 PDF 了。医院打出来的 PDF 很多其实就是一张图,没有文字层,直接读只能读到空。桌面上有 OCR 兜底,手机上没有。现在的做法是:发现没有文字层,就把每页渲染成图,交给识别引擎,再把认出来的字填回这份文档。顺手还修了一个坑:安卓上识别失败,会把照片原件一起丢掉。现在识别失败不再连累原件,只告诉你这张没认出来 —— 在我们看来,原件比识别结果更值得保住。#160
  • 2026-07-13
    iOS 换成 Apple Vision。认中文更准,而且 iPhone 拍出来默认就是 HEIC 格式,它能直接读,不用先转一道。#114
  • 2026-07-12
    不再假装读到了字。两件事:扫描版 PDF 识别失败时,以前会把空结果当成「读到了文字层」存进去,现在老老实实标成「已存原件 · 未识别」;单张图片的记录里也如实写清到底是哪个引擎跑的(Vision / WinRT),而不是笼统写成 ONNX。读不出来就说读不出来,比给你编一个结果安全。#69 · #70
  • 2026-07-11
    五个平台、四套引擎,凑齐了。macOS 优先用 Apple Vision(读不出来再退回 PP-OCRv5),Windows 用 Windows.Media.Ocr,Linux 用 PP-OCRv5,iOS 用 Vision,安卓用 ML Kit,再补上 HEIC 解码。挑的基本都是系统自带的能力,因为它们本地就能跑完。一处要说清楚:PP-OCRv5 不是系统自带的,它的模型文件会在第一次用到时联网下载一次。下载的是模型,不是你的病历,但这确实是一次联网,不该含糊过去。这条后来只对电脑成立:手机端从 7 月下旬起也用 PP-OCRv5,但模型是编进 app 里一起发的,联网下载那条路在手机上关着(见上面 7 月 22 日那条)。#42 · #50

两件我们不打算藏着的事。第一,我们试过让视觉大模型直接「看」中文化验单的照片,在我们试的那几张上,它编出了根本不存在的化验项目名,还编得挺像真的。这种错放在病历里风险太高,所以这条路我们先放下了,老老实实走 OCR → 文字 → 抽取。第二,同一批手机实拍的照片,Apple Vision 给出的识别置信度落在 0.36–0.41,PP-OCRv5 落在 0.69–0.95;43.60、4.35 这几个数字,PP-OCRv5 读对了、Apple Vision 读错了。得说明的是,两个引擎的置信度是各算各的,数值之间不能直接比高低,能比的是「读出来的字对不对」——我们看的是后者。这段话最早写下来的时候还带着一句「可惜 PP-OCRv5 目前只在桌面上跑,手机用不了」;现在不是了 —— iOS 在 7 月 22 日、安卓在 7 月 24 日都换了过去,两端跑的是同一个引擎、同一套模型,过程在上面那两条里。一句边界说清楚:PP 依赖的 ONNX Runtime 只有 arm64 安卓的预编译库,别的 ABI 构建不出来;我们发布的安卓安装包也只出 arm64-v8a,落地页上写了「需 64 位手机」。

What you actually care about: will it read my photo correctly?

Getting a phone to read a crooked photo of a lab report

Recognition and organizing both happen on your own device — your photos and PDFs don't come to us, and we don't run servers that store them. The price: there is no ready-made engine we can just pick up. Each OS only lends you its own OCR, and they are not equally good. We later replaced the system engine on phones and shipped our own, for a specific reason — it was getting digits on lab reports wrong, and the entry below names them. This card tracks how we assembled that path across five platforms, including the day we overturned our own earlier call.

  • 2026-07-24
    Android moved to PP-OCRv5 too — same engine, same models as iOS. Android had been on ML Kit. After the switch, both phones run the same Rust code against the same model files, so one fix lands on both. That's what makes the next card — the four places a photo can break — a single change instead of two. Android also needs libc++_shared.so packaged in, or PP crashes on device. b7cc3e9 · 5ca2910
  • 2026-07-22
    iOS switched from Apple Vision to PP-OCRv5 — overturning what we wrote ourselves on July 13, below. On real photos of Chinese lab reports, Apple Vision dropped a digit: red blood cell count 4.35 read as 1.35, mean corpuscular volume 90.1 as 50.1, hematocrit 39.2 as 19.2, neutrophil percentage 43.60 as 4.60. On the same images PP-OCRv5 returned the printed values. One digit turns normal into abnormal; that error can't live in a medical record. The cost went into the decision record too: the iOS package grew from 59MB to 88MB (about +29MB), and CPU inference runs 2–3× slower than Vision on the neural engine. The models (detection + recognition + dictionary, ~20MB) are compiled into the Rust static library with include_bytes! and dropped into the sandbox on first use — there's no writable model directory in an iOS sandbox, so the download path is switched off on phones. 505c23c
  • 2026-07-22
    The hard part of linking PP-OCRv5 into iOS. pyke's prebuilt libonnxruntime.a contains 28 duplicate objects that conflict with each other. Ordinary on-demand linking doesn't care — the linker takes the first definition of each symbol. But cargokit puts -force_load on the whole Rust static library by default, so that the entry points the Dart runtime finds via dlsym don't get stripped as dead code — and -force_load pulls in every object, which produced 757 duplicate symbols. Drop either copy and symbols go missing instead. This library can't be force-loaded whole. So we kept the entry points a different way: no force_load, let onnxruntime link on demand, and use -Wl,-u to pin the bridge runtime's 14 fixed symbols as link roots — one dispatcher statically references every wire function, so keeping it drags in the whole closure (wire → ocr → ort → onnxruntime) on demand. Plus -lc++ -framework CoreML: the link directives ort's build script emits only count when cargo does the linking, and don't survive to Xcode's final link. 505c23c
  • 2026-07-20
    Scanned PDFs: wrong twice before right. On phones, a PDF with no text layer gets rendered to images and recognized. The render scale was hardcoded at page.width * 2. We took pdfx's width to be in points, converted that to 144 DPI, decided it was under the 300 DPI floor people use for Chinese OCR, and raised it to 4.17×. One look at the docs would have shown the mistake: pdfx states page.width is in pixels, not points. The scale went far past what was intended, render() returned null on device, and the line if (img == null) continue; skipped the whole page without a word — turning "recognition is off" into "not a single character". The second fix stopped betting on one constant: try render scales 3× → 2× → 1.5× and take the first that comes back, logging every rung that fails. A white background went in at the same time — render()'s PNG is transparent by default, and black text over transparency can composite onto black when OCR loads it. Two lines from that day's log are worth keeping: the arithmetic was fine, the premise was wrong, and correct arithmetic makes a wrong premise look credible; and silent failure is an amplifier. #173 · #175
  • 2026-07-18
    Phones can read scanned PDFs now. Many hospital PDFs are really just images — no text layer, so reading them yields an empty string. Desktop had an OCR fallback; mobile didn't. The fix: detect the missing text layer → render each page to an image → hand it to the recognition engine → backfill the recognized text. Shipped alongside: Android used to drop the original photo when recognition failed. OCR failure no longer takes the original down with it — we just tell you that page wasn't recognized. We think the original is worth more than the extraction. #160
  • 2026-07-13
    iOS switched to Apple Vision. Better Chinese recognition, and it reads the HEIC files iPhones save by default — no conversion step. #114
  • 2026-07-12
    We stopped pretending we'd read something. Two fixes: when OCR failed on a scanned PDF, the empty result used to be stored as a "successful text layer" — now it honestly degrades to "original stored · not recognized". And for single images, provenance records name the engine that actually ran (Vision / WinRT) rather than lumping everything under ONNX. Saying "couldn't read it" is safer than inventing a result. #69 · #70
  • 2026-07-11
    Five platforms, four engines, all wired up. macOS prefers Apple Vision and falls back to PP-OCRv5 when Vision comes up empty; Windows uses Windows.Media.Ocr, Linux uses PP-OCRv5, iOS uses Vision, Android uses ML Kit, plus HEIC decoding. We picked OS-provided engines wherever we could, because they run start to finish locally. One thing to be clear about: PP-OCRv5 isn't one of them — its model files are downloaded once, over the network, the first time they're needed. What gets downloaded is the model, not your records, but it is a network call and we shouldn't gloss over it. That line later became desktop-only: phones run PP-OCRv5 too since late July, but the models ship inside the app and the download path is switched off there (see the July 22 entry above). #42 · #50

Two things we're not hiding. First: pointed at photos of Chinese lab reports, a vision-language model invented test names that weren't there — convincingly — on the samples we tried. That kind of error is too risky to carry into medical records, so we've set the approach aside for now and stick to OCR → text → extraction. Second: on the same set of real phone photos, Apple Vision reported confidence in the 0.36–0.41 range against 0.69–0.95 for PP-OCRv5, and PP-OCRv5 read values like 43.60 and 4.35 correctly where Apple Vision didn't. Worth noting: the two engines compute confidence differently, so the numbers aren't directly comparable — what is comparable is whether the characters came out right, and that's what we went by. When this paragraph was first written it ended with "PP-OCRv5 is desktop-only today" — that's no longer the case. iOS moved on July 22, Android on July 24; both phones now run the same engine against the same models, and the two entries above tell how. One boundary worth stating: ONNX Runtime, which PP depends on, ships prebuilt libraries for arm64 Android only and other ABIs don't build — and the Android package we publish is arm64-v8a only, which the download page states as "64-bit phone required".

用户在意的:同样一张照片,为什么有时候读得好,有时候一团糟

一张照片从按下快门到变成一张表,中间有四道坎

「它没读出来」听着像一个问题,拆开是四个,而且不在同一层:相机可能压根打不开;字可能没被找到(不是认错,是没走到认字那一步);整页可能是躺着的;甚至每个字都认对了,屏幕上还是一团乱。这四种毛病到了用户嘴里是同一句话。所以我们把顺序固定下来:先弄清坏在哪一层,再动那一层。

  • 2026-07-26
    渲染:看着像行列颠倒,字其实没认错。手机上的表格像是行和列换了个个儿。先出了一版什么都不修的改动:把设备上每个检测框的中心、宽高和识别出的文字都打印出来,和桌面逐个比。结果一致 —— 文字、坐标、朝向都对。问题在最后一层:整页转正之后是一张横过来的报告,按「≥2 个连续空格」切出来的列特别多,而 Flutter 的 TableFlexColumnWidth 把宽度平均分,窄屏上每列只剩下一个字左右的宽度,中文就顺着竖下去了,看起来就像行列颠倒。改成 IntrinsicColumnWidth(按内容自适应、不换行)加横向滚动,和桌面查看器一致。先看再改,这一步没跳过 —— 那版诊断提交自己什么都没修,可要是没有它,我们会一路去调 OCR,而 OCR 从头到尾是对的。23b78a1 · 8634aa9
  • 2026-07-26
    朝向:把一个模型打进包里,又把它删掉。整页躺 90° 的照片,两端都不会自动转正 —— 透视校正纠的是斜,不管朝向。先接的是 PP 家现成的文档方向分类模型(PP-LCNet_x1_0_doc_ori),6.8MB 打进手机包。桌面上转得好好的,华为那台真机就是不转。补了一版走显式模型路径的诊断,把范围缩到「Android 上的 onnxruntime 跑这个模型,输出和桌面不一致」;而且它在 oar-ocr 里是逐切片跑的,对「整页朝哪边」这个判断来说,粒度本来就不对。于是换个想法:判断整页朝向不需要黑箱。文档正着放,文字行的检测框普遍是扁的(宽 > 高);整页躺倒,每一行就变成一根竖条。竖框占比过半就当它躺倒,把图转 90° 和 270° 各重识别一遍,按识别置信度挑正的那个 —— 正着读置信度高,倒着是乱码。确定性、两端共用一套、复用已经跑通的检测模型,包里也不用多带一个模型;doc-ori 和那 6.8MB 一起删了。后面还补了一步:转正之后再按文字方向去一次斜,不然斜着拍的行仍旧是塌的。ce5f59f · ada00aa · 74c34df · 10ff5cf
  • 2026-07-26
    采集:没有 Google 服务的手机,相机打不开。拍照走的是文档扫描器 —— 自动画框、透视拉正,斜着拍也能拉回横平竖直。安卓这边它背后是 ML Kit 的文档扫描器,而按 Google 官方文档,这个 API 只由 Google Play 服务交付,没有内置的离线版本(ML Kit 的文字识别有内置版,文档扫描器没有):模型、界面、逻辑都靠 GMS 按需下载。纯 HMS 的国产机上,或者连不上 Google 的网络环境里,这个模块下不来。更麻烦的是它不抛异常:插件自己转去兜底的手动裁剪器,而那个用设备相机、要 CAMERA 权限 —— 我们没声明。于是用户点「拍照」,相机直接打不开,我们外面套的 try/catch 什么也接不到。「出了事我能接住」是个假设,不是事实。改法是开拍前先问一句系统里有没有 Google 服务:有,就用扫描器(自动拉正,不为了兼容把好机器一起降级);没有,就退回系统自带的相机 app —— 走 intent,不需要我们声明 CAMERA 权限,也不依赖 Google 服务。退回普通相机就没有自动画框和透视拉正了:整页躺倒和倾斜由下游的转正和去斜接手,四角透视那一层安卓还没做,记在待做里。iOS 用的是苹果自己的 VisionKit,跟 GMS 无关,不动。0f39886
  • 2026-07-23
    检测:整段读不出来,不是认错字,是压根没被找到。用户反馈:两张存在小程序里的高截图(超声、MR),报告标题识别了,正文整段是空的。查 oar-ocr 的检测配置查到原因:PP 的检测模型默认 limit_side_len=960,不管原图多大,先把长边压到 960px 再检测。一张长边两千多的高截图被压成 0.375 倍,密集的小字就掉到检测分辨率以下 —— 没走到认字那一步;标题字大,活了下来。改法是纵向切片:按 1100px 一带、相邻两带上下各留 120px 重叠,把长图切开,每带用接近原生的分辨率单独跑,再按「这一行的中心落在哪一带的核心区」归属去重,合回整图坐标。下游拿到的仍然是整图坐标,零改动;图不够高就走单次识别,和以前逐字节一样。为了在桌面上复现,我们另拿一份真实报告堆成 960×2560、压到同样的 0.375 倍:单次整图检出 126 行,切片之后 199 行。(这是两种做法在同一张构造图上的差,不是产品的识别指标;用户那两张截图没在桌面上跑过。)与其把 960 调大 —— 那只是把悬崖挪到更高的图上 —— 不如多加一条带。04a602f

四道坎里有三道的症状是一样的。相机打不开是明的,剩下三道在用户那儿都长成同一句「它没读出来」。所以现在的顺序是:先把中间产物打印出来看 —— 框在哪、多大、认出来什么字 —— 再决定动哪一层。还有一件事想说清楚:这四条里安卓和 iOS 的活儿不对称,起点就不一样。iOS 的采集走苹果自己的 VisionKit,导入的图还有原生的文档检测和拉正,那套现成好用,我们没理由再造一遍;安卓没有对应的原生件,所以采集那一条只修了安卓,四角透视也还没做,在待做里。朝向反过来 —— 它做在两端共用的 Rust 里,改一次两边都拿到。

What you actually care about: same photo — why is it clean sometimes and a mess other times?

Four places a photo can break between the shutter and the table on your screen

"It didn't read" sounds like one problem. It's four, and they sit on different layers. The camera may not open at all. The characters may never be found — not misread; they simply don't reach the recognizer. The page may be lying on its side. And even with every character correct, the screen can still be a mess. All four reach us as the same sentence. So we fixed the order of operations: work out which layer broke before touching anything.

  • 2026-07-26
    Rendering: it looked transposed; the characters were fine. Tables on the phone looked transposed. The first change fixed nothing on purpose: dump every detection box from the device — center, width, height, recognized text — and compare it box by box against desktop. Identical. Text, coordinates, orientation all correct. The break was in the last layer: once uprighted, the report is landscape, splitting on "two or more spaces" yields a lot of columns, and Flutter's Table with FlexColumnWidth divides the width evenly — on a narrow screen each column ends up about one character wide, so Chinese stacks vertically and reads like a transposed table. Switched to IntrinsicColumnWidth (sized to content, no wrapping) plus horizontal scrolling, matching the desktop viewer. We looked before we changed anything — that diagnostic commit fixed nothing by itself, but without it we'd have gone off tuning OCR, and OCR was right the whole time. 23b78a1 · 8634aa9
  • 2026-07-26
    Orientation: we shipped a model into the app, then took it back out. A page photographed 90° over doesn't right itself on either platform — perspective correction fixes tilt, not orientation. First attempt: PP's off-the-shelf document-orientation classifier (PP-LCNet_x1_0_doc_ori), 6.8MB compiled into the phone package. It worked on desktop; on a Huawei device it didn't rotate. A diagnostic pass with an explicit model path narrowed it to "Android's onnxruntime produces different output from desktop for this model" — and in oar-ocr the classifier runs per slice anyway, which is the wrong granularity for a decision about the whole page. So we changed the question: deciding which way up a page is doesn't need a black box. In an upright document, text-line detection boxes are wide and short; rotate the page and every line becomes a tall narrow strip. If more than half the boxes are tall, treat the page as sideways, re-run recognition on the 90° and 270° rotations, and keep the one with the higher recognition confidence — upright reads cleanly, upside-down comes back as garbage. Deterministic, one implementation for both platforms, reusing the detection model that already runs on both platforms, and no extra model in the package. The classifier and its 6.8MB came out. A follow-up step went in after: once uprighted, deskew along the text direction, or rows from a tilted shot still collapse. ce5f59f · ada00aa · 74c34df · 10ff5cf
  • 2026-07-26
    Capture: on a phone without Google services, the camera doesn't open. Taking a photo goes through a document scanner — automatic edge detection and perspective correction, so a shot taken at an angle comes back square. On Android that scanner is ML Kit's, and per Google's own documentation this API is delivered through Google Play services only, with no bundled offline version (ML Kit text recognition has one; the document scanner doesn't): model, UI and logic are downloaded by GMS on demand. On a Huawei-style HMS-only device, or on a network that can't reach Google, that module never arrives. Worse, it doesn't throw: the plugin quietly falls back to its own manual cropper, which uses the device camera and needs the CAMERA permission — which we hadn't declared. So the user taps "take photo" and the camera simply doesn't open, while our try/catch never sees a thing. "An exception will reach me" was an assumption, not a fact. The fix: ask whether Google services are present before starting. If yes, use the scanner — no reason to degrade good devices for the sake of the others. If no, fall back to the system camera app via intent, which needs no CAMERA declaration from us and no Google services. Falling back to the plain camera means no automatic edge finding and no perspective correction: a page lying on its side or tilted is picked up downstream by uprighting and deskewing, while four-corner perspective correction on Android is still on the list. iOS uses Apple's own VisionKit, which has nothing to do with GMS, and stays as it is. 0f39886
  • 2026-07-23
    Detection: a whole section came back empty — not misread, never found. A user reported two tall screenshots saved from a mini-program — an ultrasound and an MR: the report headings were recognized, the bodies were blank. The reason turned up in oar-ocr's detection config: PP's detector defaults to limit_side_len=960, shrinking the long edge to 960px before detection no matter how large the source is. A screenshot over 2000px tall gets scaled to 0.375×, and its dense small type falls below the detector's resolution — it never reaches the recognizer at all. Headings are large, so they survive. The fix is vertical banding: cut the tall page into 1100px bands with 120px of overlap on each inner side, run each band near native resolution, then deduplicate by which band's core a line's center falls into and merge back into full-image coordinates. Everything downstream still receives full-image coordinates and needed no changes; pages that aren't tall take the single-pass path, byte-for-byte as before. To reproduce it on desktop we stacked a different real report into a 960×2560 page and squeezed it by the same 0.375×: 126 lines detected in a single pass, 199 after banding. (That is the gap between two methods on one constructed page, not a product accuracy figure; the user's two screenshots were never run on desktop.) Raising 960 would only move the cliff to a taller image; adding a band doesn't. 04a602f

Three of the four look identical from outside. A camera that won't open is obvious; the other three all arrive as the same sentence — "it didn't read". Hence the order we now follow: print the intermediate artifacts first — where the boxes are, how big, what text came out — then decide which layer to touch. One more thing worth saying: the work here is asymmetric between Android and iOS, because the starting points were. iOS captures through Apple's VisionKit and already has native document detection and rectification for imported images; that works, and rebuilding it would buy nothing. Android has no equivalent, so the capture entry above is Android-only, and four-corner perspective correction there is still on the list. Orientation runs the other way — it lives in the Rust both platforms share, so one change lands on both.

用户在意的:它会不会看错、会不会瞎编

认出「这是一种药、那是一项指标」,而且不许猜

读出字只是第一步。一份出院小结里,「二甲双胍 0.5g 每日两次」是用药,「谷丙转氨酶 43.60 U/L」是化验,「2 型糖尿病」是诊断 —— 得先分得清,才谈得上按时间给你理成一条线。难点不在认出常见词,在于同一个东西有十几种写法(缩写、商品名、中英混排、单位不统一),而且宁可漏,也不能编

  • 2026-07-18
    先分段,再抽取。以前是拿整份文档从头扫到尾,结果出院小结里夹着的用药段常常被漏掉。现在先按「出院医嘱 / 出院带药 / 检验结果」这类小标题把文档切成段,再按段的类型决定用哪套规则。出院小结里夹着的那段出院医嘱,以前会被整段丢掉,现在能抽出来。#160
  • 2026-07-16
    给医生看的摘要,挑毛病挑出六处。摘要里有六类错会露出来:诊断被行内编号切碎、同一项重复出现、病理结论被当成诊断、化验和用药串味、参考值读错、趋势排序乱。都修掉了。这活儿不性感,但摘要要给医生看,错一条就可能误导一次判断。#149
  • 2026-07-15
    化验值、用药、诊断,三样都能确定性地抽出来了,并且跨文档合并。「确定性」的意思是:同样的输入得到同样的输出,规则写在代码里,能一条条讲清为什么这么判 —— 而不是模型今天这样答、明天那样答。取舍也说清楚:规则的代价是吃排版。病历的写法千差万别,编号、分行、有没有小标题都会影响规则怎么切 —— 所以我们才一层层加结构识别(先分段、再按段路由),而不是指望一套正则通吃。散文段落最终要交给自己微调的小模型,原因也在这。#134 · #135
  • 2026-07-14
    把「同一个东西的十几种写法」归到一起。药名先做确定性剥壳(去掉剂型、规格、商品名外壳),再用单位当证据消歧(同一个缩写,后面跟 mmol/L 还是 mg 意思完全不同),配上最长匹配。术语字典从 191 条扩到 637 条。#126 · #128 · #130
  • 2026-07-10
    先有一层术语归一,再谈别的。最早的 191 条字典,覆盖化验、体征、药物三类。#23 · #24

最难的一块是化验项目的同义词。同一个指标,不同医院能写成完全不同的样子:全称、英文缩写、旧称、加不加括号里的方法学。药名还能靠剥壳和单位证据收敛,化验项目的写法空间要大得多 —— 所以术语字典这层要一直扩下去,单靠规则收不干净。「直接换个更大的模型就行了」也不成立,但原因和直觉不同:现成医疗模型读文字其实不差 —— 我们自标的 14 份文档上,用药 F1 0.97、疾病召回满分;真正拖后腿的恰恰是上面那些同义词。会编的是直接吃照片那条路(见前面让它自己看图那段)。所以方向是:规则打底,散文段落交给一个我们自己微调的小模型,而不是让通用大模型自由发挥。

What you actually care about: will it misread things, or make them up?

Telling "this is a drug" from "that is a lab value" — without guessing

Reading the characters is only step one. In one discharge summary, "metformin 0.5g twice daily" is a medication, "ALT 43.60 U/L" is a lab result, and "type 2 diabetes" is a diagnosis. You have to tell them apart before you can lay anything out on a timeline. The hard part isn't the common words — it's that the same thing gets written a dozen ways (abbreviations, brand names, mixed Chinese/English, inconsistent units), and that missing something is acceptable while inventing something is not.

  • 2026-07-18
    Split into sections first, then extract. We used to scan each document end to end, which meant medication lists buried inside a discharge summary were often missed. Now we cut the document at headings like "discharge orders / discharge medications / lab results" and pick the rule set per section type. The discharge-order line buried inside a summary used to be dropped wholesale; now its medications come through. #160
  • 2026-07-16
    Six defects found in the doctor-facing summary. Six kinds of error were surfacing in the summary: diagnoses chopped up by inline numbering, the same item repeated, pathology conclusions treated as diagnoses, labs and medications bleeding into each other, reference ranges misparsed, trends sorted wrong. All fixed. Unglamorous work — but a doctor reads this summary, and one wrong line can misdirect one decision. #149
  • 2026-07-15
    Lab values, medications and diagnoses all extract deterministically now — and merge across documents. "Deterministic" means the same input yields the same output, the rules live in code, and we can explain any single call — instead of a model answering one way today and another way tomorrow. The trade-off, stated: rules key off layout. Records are written every which way, and numbering, line breaks and whether headings exist all change how rules segment them — which is why we keep adding structure awareness (split into sections first, route per section) instead of hoping one set of regexes covers everything. Prose sections are headed for a small model we fine-tune, for the same reason. #134 · #135
  • 2026-07-14
    Collapsing the dozen spellings into one. Drug names get deterministically stripped of dosage form, strength and brand wrapper; units then serve as disambiguating evidence (the same abbreviation means very different things followed by mmol/L versus mg), with longest-match on top. The terminology dictionary went from 191 entries to 637. #126 · #128 · #130
  • 2026-07-10
    A terminology layer had to come first. The original 191-entry dictionary, covering labs, vitals and drugs. #23 · #24

The hardest part is synonyms among lab items. The same measurement gets written completely differently across hospitals: full name, English abbreviation, legacy term, with or without the assay method in parentheses. Drug names can be pulled together by stripping and by unit evidence; the space of lab spellings is far larger — which is why the terminology dictionary has to keep growing, and why rules alone won't close it. "Just swap in a bigger model" doesn't hold either, though not for the obvious reason: an off-the-shelf medical model reads text reasonably well — 0.97 F1 on medications and perfect recall on conditions across the 14 documents we hand-labelled; what drags it down is exactly the synonym problem above. The confident fabrication came from feeding it photographs instead (see the "let it read the image" entry earlier). Our direction: rules as the floor, with prose sections handed to a small model we fine-tune ourselves, rather than letting a general-purpose LLM improvise.

用户在意的:到了诊室,这东西真能用吗

把三年病历,压成医生几十秒能看完的一页

门诊留给你的时间通常只有几分钟。你翻着手机找化验单的时候,医生只想知道三件事:什么病、吃什么药、指标在往哪个方向走。所以我们要做的不是"把所有资料都给他",而是把最要紧的挑出来排在最前面,同时让他随时能点回原件核对 —— 医生不会信一份看不到出处的摘要。

  • 2026-07-18
    摘要不该动不动就是空的。以前只有识别出「诊断」才生成摘要,于是一堆只有化验或影像的分享打开是空白。现在诊断、趋势、影像、病理、过敏,任意一项有内容就生成。同时补上了病理结论的展示 —— 以及一个存在很久的 bug:复制出来的病历文本里,影像和病理那两栏一直是空的,因为代码读错了字段名。#160
  • 2026-07-15
    分享时自动生成真实摘要,而且全线只有一个查看器。以前查看器有两份、各自漂移;统一成一份之后,分享出去的和医生打开看到的,是同一套东西。#138
  • 2026-07-15
    「复制病历文本」按院外就诊经历的格式来。医生要往自己系统里粘贴,那就按他习惯的格式给,而不是给一段我们自己觉得好看的排版。#133
  • 2026-07-14
    疾病泳道时间轴 + 化验和用药的趋势。把同一个病的就诊串成一条线,把同一个指标的历次数值连成一条趋势。这里能跨医院,是因为资料在你手上 —— 医院自己的系统通常只看得到本院的那部分。#132
What you actually care about: in the exam room, does this actually work?

Compressing three years of records into a page a doctor can read in under a minute

A clinic visit gives you a few minutes. While you're scrolling your phone hunting for a lab report, the doctor wants three things: what conditions, what medications, which direction the numbers are moving. So the job isn't "hand over everything" — it's putting what matters at the top while keeping the original one tap away. No doctor trusts a summary they can't check against the source.

  • 2026-07-18
    The summary shouldn't keep coming up empty. It used to require a recognized diagnosis, so shares containing only labs or imaging opened blank. Now any of diagnoses, trends, imaging, pathology or allergies is enough. Pathology conclusions are now displayed too — along with a long-standing bug: in the copied record text, the imaging and pathology fields were empty because the code read the wrong field names. #160
  • 2026-07-15
    Sharing generates a real summary, and there's now exactly one viewer. There used to be two viewer copies drifting apart from each other. With one source, what gets shared and what the doctor opens are the same thing. #138
  • 2026-07-15
    "Copy record text" follows the outside-visit format doctors use. They're going to paste it into their own system, so it should arrive in the shape they expect — not in whatever layout we happen to find pretty. #133
  • 2026-07-14
    Condition swimlanes on a timeline, plus lab and medication trends. Visits for one condition strung into a single line; repeated measurements of one marker joined into a trend. It can span hospitals because the records are in your hands — a hospital's own system usually only sees its own share of them. #132
用户在意的:发给医生的那个文件,他打得开吗

一个不用装、不用注册、不联网的查看器

分享这一步的约束很硬:你没法指望医生为你装个 App、注册个账号,诊室的网也未必好使。当时的答案不是链接,是一个文件 —— 一份自包含的加密 HTML,记录用 AES-256-GCM 加密塞在里面,查看器本身也在里面。口令另外一个渠道告诉他。他双击打开,输口令,浏览器本地解密。2026-07-28 更新:手机端已撤掉这条路 —— 实测 iOS 微信收到 .html 根本打不开,而二维码扩展到完整病历后也不再需要它;桌面端仍有该能力,已发出去的文件用查看器照常打开。难的地方在于,这个文件一旦发出去就不归我们管了 —— 它得自己把该做的都做完,还得让医生信它。

  • 2026-07-16
    清掉「已过期」拦截屏留下的最后一点样式。拦截屏本身早在 7 月 10 日就拿掉了 —— 查看器里原本有一道检查,过期就挡一屏不让看。后来想明白了:文件已经在对方手里,浏览器里的一个判断挡不住任何人;留着它,等于向用户承诺一个我们并不具备的撤回能力。挡人的那道拦截没了,日期没有:它降级成一条不挡路的提醒横幅,告诉医生这份是什么时候导出的、要不要回头找本人确认最新情况。宁可让一个功能退回它本来的分量,也不要拿它冒充权限。这次只是把它遗留的死样式扫干净。#140
  • 2026-07-15
    一份查看器,三个地方共用。以前查看器有两份代码:一份写在生成分享文件的 Rust 里,一份是托管页那个 HTML(GitHub Pages 上跑的就是它)。改一处另一处不跟,慢慢就长歪了。现在只留后面这一份,分享文件在编译时把它整个嵌进来,再注入这一份的加密数据 —— 桌面和手机本来就共用同一个分享模块,所以桌面发出去的、医生在浏览器里打开的、我们托管的那页,是同一份东西 —— 打开时自己判断:带数据就是自包含模式(输口令),不带就是托管模式(拖文件进来)。这一步删掉了约 700 行重复代码。#138
  • 2026-07-14
    疾病泳道时间轴,和每个数字背后的「原件」。每个病一条从起病拉到现在的横条,点开就地展开这个病的化验趋势和用药。数据模型照 FHIR 的 Condition 和 OMOP 的 condition_era 来,不是我们自己拍的结构。更要紧的是旁边那个「原件」标签:每个化验值、每条用药都挂一个,点了直接跳回它出自哪份文档,还给一个「返回」按钮跳回来。摘要是我们整理的,原件不是 —— 得让医生随时能自己核。#132
  • 2026-07-12
    PDF 原件整份嵌进去。不只给识别出来的文字,把原始 PDF 也一并放进这个文件里,医生能下载下来对着看 —— 结构化的东西是辅助,原件才是真相,这条我们从设计文档里一路带到了产品里。整份分享有个体积上限,顶到了就不再嵌原件、只留识别文字;这种情况会在那张卡片上写明,不静默吞掉。#82
  • 2026-07-12
    托管的那份改成自动部署。之前是手工传上去的,结果线上那份落在仓库后面好几个提交。手工步骤迟早会漏,所以接了 CI。#74
  • 2026-07-11
    把数据从可执行的脚本里挪出来。查看器里跑着解密后的病历,原来的策略允许执行任意内联脚本,万一哪天混进一段注入,它就能拿着这些数据往外发。做法是:把每份分享的加密数据挪进一个不可执行的 JSON 节点,查看器运行时去读。这么一改,页面里剩下的两段内联脚本就变成了字节固定的常量 —— 固定,才能对它们算哈希、写进策略里精确放行,script-src 里的 unsafe-inline 也就能拿掉了(样式那边至今还留着)。顺手把导航、表单、被嵌进别人页面的路都锁死。#38

几处细节,顺手记下来。这份查看器的安全策略第一行就是 default-src 'none'connect-src 'none',文件里没有外部引用 —— 它没地方可连,也不需要连。里面的 DICOM 阅片也是同一个思路:只用一个 dicom-parser 加浏览器自带的 canvas 画,不引 Cornerstone 那套渲染栈(只借了它家的解析器),不用 WASM、不开 worker,按需解码、缓存有上限 —— 因为它得能塞进一个单文件里跟着走。能直接画的是未压缩的和 JPEG baseline 这两类;别的压缩格式、以及大到超过体积上限的序列,会降级成关键切片加一句说明,同样不静默。还有一个很小但很容易踩的坑:口令按 4 个字符一组显示,分隔符只能用空格,不能用连字符 —— - 本身就是 base64url 字母表里的字符,当成分隔符删掉会把密钥改坏。

What you actually care about: can the doctor actually open the file I send?

A viewer with nothing to install, no account, and no network

The constraints here are unforgiving: you can't count on the doctor installing an app or signing up for an account, and clinic wifi is a coin flip. The answer at the time wasn't a link — it was a file. One self-contained encrypted HTML, with the records AES-256-GCM encrypted inside it and the viewer inside it too. The passphrase travels separately. Update 2026-07-28: the phone no longer offers this path — iOS WeChat cannot open a received .html at all, and once the QR code grew to carry the full record it was no longer needed. Desktop keeps the capability, and files already sent still open in the viewer. Double-click, type the passphrase, and the browser decrypts locally. The hard part is that once the file is sent, it's out of our hands — it has to do everything itself, and it has to earn the doctor's trust.

  • 2026-07-16
    Swept out the last styles left behind by the "expired" blocking screen. The screen itself went back on July 10 — the viewer used to check an expiry date and put up a wall past it. Then it clicked: the file is already on their machine, and a check running in their browser stops nobody. Keeping it meant promising users a revocation power we don't have. The wall is gone; the date isn't — it degraded into a non-blocking banner that tells the doctor when this export was made and suggests checking back with the patient for anything newer. Better to let a feature shrink to its real weight than let it pose as access control. This PR only cleaned up its dead CSS. #140
  • 2026-07-15
    One viewer, used in three places. There used to be two copies of the viewer code: one inside the Rust that generates share files, one in the hosted HTML (what GitHub Pages serves). Fix one, the other drifts. Now only the latter is the source: the share file embeds it whole at compile time and injects that share's encrypted data. Desktop and mobile already share the same sharing module, so what you send from your phone, what the doctor opens in a browser, and the page we host are one and the same. On open it checks itself — data present means self-contained mode (type the passphrase), absent means hosted mode (drop a file in). About 700 lines of duplication went away. #138
  • 2026-07-14
    Condition swimlanes, and a "source" tag on every number. Each condition gets a bar running from onset to now; click it and that condition's lab trends and medications expand in place. The data model follows FHIR Condition and OMOP condition_era rather than a shape we invented. What matters more is the "source" tag next to each lab value and medication: tap it and you jump to the document it came from, with a button to jump back. We assembled the summary; we didn't write the originals — so checking one against the other has to stay one tap away. #132
  • 2026-07-12
    The whole original PDF goes in the file. Not just the recognized text — the source PDF rides along so the doctor can download it and check against it. The structured view assists; the original is the truth. That line came from our design docs and made it all the way into the product. There's a size ceiling on the whole share: once it's hit, originals stop being embedded and only the recognized text remains — and when that happens the card says so rather than swallowing it quietly. #82
  • 2026-07-12
    The hosted copy now deploys itself. It used to be uploaded by hand, and the live copy ended up several commits behind the repo. Manual steps get skipped eventually, so CI took it over. #74
  • 2026-07-11
    Moved the data out of executable script. The viewer holds decrypted records, and the old policy allowed any inline script to run — so any injection that ever slipped in could carry that data out. The fix: each share's encrypted blob moved into a non-executable JSON node that the viewer reads at runtime. That left the page with exactly two inline scripts whose bytes never change — and fixed bytes can be hashed and named in the policy, which is what let us drop unsafe-inline from script-src (it's still there for styles). Navigation, form submission and being framed by another page got locked down in the same pass. #38

A few details worth writing down. The viewer's security policy opens with default-src 'none' and connect-src 'none', and the file carries no external references — there's nowhere for it to phone, and no need. DICOM reading inside it follows the same logic: just dicom-parser plus the browser's own canvas — none of the Cornerstone rendering stack (we borrowed their parser and stopped there), no WASM, no worker, decoding on demand against a bounded cache, because it has to fit inside a single file that travels. What it draws directly is uncompressed and JPEG baseline; other compressed formats, and series past the size ceiling, degrade to a key slice plus a note — again, said out loud rather than swallowed. And one small trap that's easy to fall into: the passphrase displays in groups of four, separated by spaces, never hyphens — - is itself a character in the base64url alphabet, so stripping it as a separator would corrupt the key.

用户在意的:到了诊室,怎么把病历送到医生眼前

不装 App、不过医院的网,让医生扫一下就看到你现在什么情况

我们本来有一份加密分享文件,医生下载下来能看整份病历。可真到门诊里,送达这一步常常卡住:文件得先传过去,医生还得找到它、打开它。我们试过微信 —— 文件能收到,iOS 上没有一键打开的办法。医院里那台连内网的工作站也指望不上,它和微信基本不会同时出现在一台机器上。于是换个问法:如果医生手里只有他自己那部手机,能不能让他三十秒看懂你现在什么情况?答案是一张码。而这张码有个很硬的上限。

  • 2026-07-28
    推翻上面那条:码里不再放载荷,改放取件号。把摘要内嵌进码里的理由是「不联网也能看」——那是错的:医生扫码打开的是一个网址,页面本身就要从网上下载,没网连页面都出不来。内嵌买不到任何东西,只把码从 63 字符撑到 1838(41×41 格 → 161×161 格),而密集的码从反光的手机屏上更难扫。现在码里只有 q2.<取件号>.<密钥>,完整病历连同每一份原件加密后临时存在瞬时云上,密钥只在码里、从不上行,保留期一到自动删除。医生扫完直接看到全部,不用再问患者要原件。
  • 2026-07-19
    端到端跑通:手机出码,医生扫码就看。保险箱里装配摘要 → 裁剪 → gzip → AES-256-GCM 加密 → 塞进 URL 的 # 之后 → 手机屏幕上显示成二维码。医生用自带的相机扫,查看器在他浏览器里解开。密钥也在 # 之后,按 HTTP 规范这部分不会随请求发给服务器 —— 医生那边只从静态页下载一个空壳查看器,病历数据不经过我们的服务器,只在两台手机之间。两个实现上的决定:fragment 带 q1. 前缀,查看器据此一眼分清「这是二维码载荷」还是「这是口令」;加密的绑定串用 medme-qr-v1,跟整份分享的 medme-share-v1 刻意不同,两种载荷不会被当成对方解开。解压直接用浏览器自带的 DecompressionStream,不引前端库。#166
  • 2026-07-18
    先解决容量:按构造裁剪,而不是指望它装得下。二维码能装的东西有个很硬的上限:二进制模式理论上是 2953 字节,但那是最低纠错等级 —— 医生隔着桌子扫,纠错得留高些,我们用的等级只剩 2331 字节。而一份完整摘要的体积跟着病历份数长,实测约 80–100 份就撑破。撑破的偏偏是慢病随访的人,而那正是最需要这东西的人群。所以我们没去压缩得更狠,而是在源头上限定带什么:只带在治的病、每个指标最近几个点、在用的药,停用的略去;容量不够时先丢已经好了的病,需要关注的排前面留住。这么一改,体积就跟病历总量脱钩了:5 个病和 500 个病,出来的码几乎一样大。有一条回归测试专门守着这件事 —— 它要是挂了,整个方案就不成立。#164

这张码换来的和放弃的,都摆在界面上。它现在带的是完整病历,含每一份原件 —— 上面 07-28 那条把「只带当下病情」这个取舍推翻了。代价是它需要联网:密文得先传上去、医生扫完再取回来,而不是像早先那样整份塞在码里。还有一句我们写进了出码页面:这张码就是钥匙。面对面出示码本身就算「在场证明」,所以不再另设口令;代价是被拍下就等于把这份摘要给了对方,没有过期,也没有撤销。顺带说清一件事:裁剪的那几个默认值(带几个病、每个病几条化验、几个点)现在是工程上的占位数,临床上该带多少,得等医生侧的反馈来定,到时候改几个常量就行,不用动管线。

What you actually care about: in the exam room, how do the records reach the doctor?

Nothing to install, nothing through the hospital network — the doctor scans once and sees where you stand

We already had an encrypted share file: the doctor downloads it and sees the whole record. In an actual clinic, though, delivery is where it stalls. The file has to get there, and the doctor has to find it and open it. We tried WeChat — the file arrives, but on iOS there's no one-tap way to open it. The intranet workstation in the room is no help either; it and WeChat rarely live on the same machine. So we asked a different question: if the only thing the doctor holds is their own phone, can they understand where you stand in thirty seconds? The answer is a QR code. And a QR code has a hard ceiling.

  • 2026-07-28
    Overturning the entry below: the code now carries a claim ticket, not the payload. We embedded the summary in the code so it would "work offline" — that reasoning was wrong: scanning opens a URL, so the page itself has to be fetched over the network. Embedding bought nothing and pushed the code from 63 characters to 1838 (41×41 modules → 161×161), and a dense code is harder to scan off a glossy phone screen. The code now holds only q2.<id>.<key>; the full record — every original included — is encrypted and parked in transient storage. The key lives only in the code and never goes upstream, and the object is deleted when its retention window ends. The doctor scans once and sees everything.
  • 2026-07-19
    End to end: the phone shows a code, the doctor scans it. Assemble the summary from the vault → trim → gzip → AES-256-GCM → put it after the # in a URL → render that as a QR code on screen. The doctor scans with their stock camera and the viewer decrypts in their browser. The key sits after the # too, and per the HTTP spec that part isn't sent to the server — the doctor's phone only pulls an empty viewer shell from a static page, and the record data travels strictly between the two phones. Two implementation calls: the fragment carries a q1. prefix so the viewer can tell "QR payload" from "passphrase" at a glance; and the AEAD binding string is medme-qr-v1, deliberately different from medme-share-v1 used by the full share, so neither payload can be opened as the other. Decompression uses the browser's own DecompressionStream — no frontend library. #166
  • 2026-07-18
    Capacity first: trim by construction instead of hoping it fits. A QR code has a hard ceiling: 2953 bytes in binary mode at the lowest error-correction level — but a doctor scanning across a desk needs more correction than that, and the level we use leaves 2331. A full summary grows with the number of records, and in practice it stops fitting at roughly 80–100. The people who burst it are exactly the ones on long-term follow-up, who are exactly the people this is for. So rather than compressing harder, we bounded what goes in at the source: conditions under treatment, the most recent few points per marker, medications currently being taken, discontinued ones left out. When space runs short, resolved conditions drop first and the ones needing attention stay at the front. The result: size decouples from how many records you have — 5 conditions and 500 conditions produce codes of near-identical size. A regression test guards exactly that; if it ever fails, the whole approach stops holding. #164

What this code buys and what it gives up are both printed on the screen. It now carries the full record, every original included — the 2026-07-28 entry above overturned that trade-off. The cost is that it needs a network — for those, the patient pulls them up on their own phone right there. That isn't a missing piece; it's the job this channel is meant to do, and the full record has the encrypted-file route. One more line we put on the sharing screen: the code is the key. Holding it out face to face is itself the proof of presence, so there's no separate passphrase. The cost is that a photo of the code hands the summary over, with no expiry and no revocation. Worth stating: the trimming defaults — how many conditions, how many labs each, how many points — are engineering placeholders. What's clinically right is for doctors to tell us, and when they do it's a few constants to change, not the pipeline.

日期按 UTC · 更新日志 · 关于 · GitHub Dates in UTC · Changelog · About · GitHub