
I keep my Zotero library in a GitHub repository. That solved the storage problem and created a new one: I could not read it on a tablet. Zotero has no Android client that writes back, and the repository is close to a gigabyte, because that is what a few thousand PDFs weigh.
ReadPaper is the answer to that. It opens a Zotero library straight from GitHub, shows the collections the way Zotero does, reads the PDFs, and puts highlights and comments on specific passages — written back into the Zotero files and committed to git.
A demo, three minutes
Browsing collections, opening a paper, highlighting, and then the part I did not plan when I started: presentation mode, drawing on a slide with a stylus, inserting a blank page mid-deck and writing on it. The deck being presented is my own Machine Learning lecture, which is a fair test — if it cannot handle my own teaching material it is not finished.
Cloning a gigabyte without cloning a gigabyte
A Zotero library in git is mostly attachments. The metadata — items, collections, notes — is small. So ReadPaper clones with --filter=blob:none and a sparse checkout that excludes the attachments folder entirely, then fetches one PDF at a time as you open it.
Measured on my own library: 23 MB and about fourteen seconds, against roughly 940 MB for a full clone. That is not a performance tweak. It is the difference between an app you can use on a mobile data plan and an app that does not exist.
Android has no git, so git got rewritten
On Linux, ReadPaper shells out to the git binary. On Android there is no binary to shell out to, so the same operations run over the GitHub REST API instead — trees, blobs, commits, refs.
The awkward part is knowing what changed. Git identifies a blob by sha1("blob <length>\0" + contents), so ReadPaper computes that itself and compares it against the tree GitHub reports. Cheap checks first — size and modification time — and the hash only when both of those moved. A push refuses outright if the remote head has moved since the last fetch. It never forces.
Byte-for-byte, and why that is correctness
ReadPaper writes back into Zotero's own JSON files. Matching Zotero's formatting exactly — tab indentation, keys in alphabetical order, children sorted by key, whole numbers without a trailing .0 — sounds like fussiness. It is not.
If the formatting drifts, adding one highlight rewrites the whole file, every sync becomes a diff nobody can read, and the first merge conflict is unresolvable. Matching it means one highlight adds one block. The round trip is verified against all 1,740 item files in my real library.
What it turned into
It started as a reader. Collections, search with operators like author:, year: and tag:, highlights and comments in Zotero's eight colours, an author facet panel, drag and drop between collections.
Then it grew a second half I had not planned:
- A whiteboard — multiple sheets, named colours, per-sheet paper size, saved as PDF.
- A stylus notebook where ink, text, images and shapes are all objects you can move, resize and rotate, with connectors that remember what they are attached to.
- A Markdown editor that renders Mermaid diagrams, converts to PDF with real selectable text, and converts PDFs back to Markdown.
- Presentation mode — full screen, pen, insert a blank page mid-deck, save, all without leaving it.
- A second root for standalone notes, beside the Zotero data rather than inside it.
Fourteen days, sixty-one commits, thirty tags.
Mermaid without a browser
The Markdown editor draws Mermaid diagrams itself — no WebView, no mermaid.js. Notes have to open on a tablet with no signal, and a JavaScript engine to draw ten boxes is a price not worth paying.
Two consequences I like. A diagram type it does not understand is refused with a reason and the source shown as written, because a diagram drawn wrongly misleads worse than code read honestly. And the layout is computed once and used twice — by the screen painter and by the PDF writer — because if each computed its own, the diagram on screen and the diagram in print would differ and the person printing would have no way to tell which was right.
The bug that quietly disabled the headline feature
A const Spacer() inside AlertDialog.actions. Flutter renders those actions in an OverflowBar, which does not accept Expanded, so every build of that dialog threw.
The dialog was the one for Highlight + comment. So the feature the whole app exists for, plus notes and annotation editing, never worked at all. Four widget tests now stand guard, including one that checks the Save button is still reachable when the keyboard is up.
Things only a real device teaches
Palm rejection is not a heuristic. The gesture detector is given supportedDevices containing the stylus alone, so a resting hand never reaches the canvas at all. Mouse stays allowed. Stylus pressure is read from raw pointer events, because the gesture detector does not hand it over — and only for the stylus, since many screens report a constant pressure for fingers.
InteractiveViewer had to go, and the cost was measurable. Its recogniser competes for one-finger drags, and the result was that the first sixty pixels of every stroke were lost before the canvas won the arena — and short drags, like a single eraser tap, never arrived at all. Pinch is now computed straight from pointer events.
A bug that looked like a missing feature. "Notes cannot be landscape" turned out to be: the paper-size button exists, but its bar sat underneath the system navigation bar. Visible, with its touches taken by the system, and the person using it reasonably concluded the feature was not there.
Android's file picker hands you a copy. Not the original file — a copy in the app's cache. So "save back to this file" is impossible, and the menu says what is true instead: Save PDF — choose where.
Where it stands
- The APK is signed with a debug key, so Android warns about an unknown source on install.
- The interface is Indonesian only for now.
- No in-PDF text search yet, no PDF outline, no EPUB.
- Git LFS works on desktop, not yet on Android.
- Screenshots in the repository are from an early desktop version. The video above is the current state.
Get it
AGPL-3.0-or-later. Use it, change it, sell it — but anyone you hand a copy to, including over a network, has the right to the source under the same terms. Contributions take a git commit -s sign-off and no CLA.
Linux desktop builds from source. There is a companion for the other half of the work: WritePaperTeX, which compiles LaTeX on the same tablet.

Library Zotero saya tersimpan di repositori GitHub. Itu menyelesaikan soal penyimpanan dan melahirkan soal baru: saya tidak bisa membacanya di tablet. Zotero tidak punya klien Android yang bisa menulis balik, dan repositorinya mendekati satu gigabita — karena memang begitulah berat beberapa ribu PDF.
ReadPaper adalah jawabannya. Ia membuka library Zotero langsung dari GitHub, menampilkan koleksinya seperti Zotero menampilkannya, membaca PDF-nya, dan menaruh stabilo serta komentar pada bagian tertentu — ditulis balik ke berkas Zotero dan langsung di-commit ke git.
Demonya, tiga menit
Menelusuri koleksi, membuka paper, menstabilo, lalu bagian yang tidak saya rencanakan waktu memulai: mode menyajikan, menggambar di atas slide dengan stylus, menyisipkan halaman kosong di tengah deck lalu menulis di situ. Deck yang disajikan itu materi kuliah Machine Learning saya sendiri — ujian yang adil: kalau bahan ajar saya sendiri saja tidak tertangani, berarti belum selesai.
Mengklon satu gigabita tanpa mengklon satu gigabita
Isi terbesar library Zotero di git adalah lampiran. Metadatanya — item, koleksi, catatan — kecil. Jadi ReadPaper mengklon dengan --filter=blob:none dan sparse checkout yang membuang folder lampiran seluruhnya, lalu menarik satu PDF saja ketika papernya dibuka.
Terukur pada library saya sendiri: 23 MB dan sekitar empat belas detik, dibanding kira-kira 940 MB untuk klon penuh. Itu bukan penghalusan kinerja. Itu selisih antara aplikasi yang bisa dipakai dengan paket data biasa dan aplikasi yang tidak ada gunanya.
Android tidak punya git, jadi git-nya ditulis ulang
Di Linux, ReadPaper memanggil biner git. Di Android tidak ada biner untuk dipanggil, jadi operasi yang sama dijalankan lewat GitHub REST API — trees, blobs, commits, refs.
Bagian yang merepotkan adalah mengetahui apa yang berubah. Git mengenali blob lewat sha1("blob <panjang>\0" + isi), jadi ReadPaper menghitungnya sendiri lalu membandingkannya dengan pohon yang dilaporkan GitHub. Pemeriksaan murah dulu — ukuran dan waktu ubah — dan hash hanya kalau keduanya bergerak. Push langsung menolak kalau head di remote sudah maju sejak fetch terakhir. Tidak pernah memaksa.
Sama persis sampai ke byte, dan itu soal kebenaran
ReadPaper menulis balik ke berkas JSON milik Zotero sendiri. Menyamai format Zotero persis — identasi tab, kunci urut abjad, anak diurutkan menurut kunci, bilangan bulat tanpa .0 di belakang — terdengar seperti kerewelan. Bukan.
Kalau formatnya menyimpang, menambah satu stabilo berarti menulis ulang seluruh berkas, setiap sinkronisasi jadi diff yang tak terbaca siapa pun, dan konflik merge pertama tidak akan bisa diselesaikan. Menyamainya berarti satu stabilo menambah satu blok. Perjalanan bolak-baliknya diuji terhadap seluruh 1.740 berkas item di library saya yang sungguhan.
Berubah jadi apa
Awalnya cuma pembaca. Koleksi, pencarian beroperator seperti pengarang:, tahun:, dan tag:, stabilo dan komentar dalam delapan warna Zotero, panel facet pengarang, seret-lepas antar koleksi.
Lalu tumbuh separuh lagi yang tidak saya rencanakan:
- Papan tulis — berlembar-lembar, warna bernama, ukuran kertas per lembar, disimpan sebagai PDF.
- Buku catatan stylus, tempat tinta, teks, gambar, dan bangun datar semuanya jadi objek yang bisa digeser, diubah ukuran, dan diputar, dengan penghubung yang mengingat apa yang disambungkannya.
- Penyunting Markdown yang menggambar diagram Mermaid, mengubahnya jadi PDF dengan teks sungguhan yang masih bisa dicari, dan membalik PDF jadi Markdown.
- Mode menyajikan — layar penuh, pena, sisip halaman kosong di tengah deck, simpan, semuanya tanpa keluar dari mode itu.
- Akar kedua untuk catatan lepas, bersebelahan dengan data Zotero, bukan di dalamnya.
Empat belas hari, enam puluh satu commit, tiga puluh tag.
Mermaid tanpa peramban
Penyunting Markdown-nya menggambar diagram Mermaid sendiri — tanpa WebView, tanpa mermaid.js. Catatan harus bisa dibuka di tablet yang sedang tanpa sinyal, dan mesin JavaScript untuk menggambar sepuluh kotak adalah harga yang tidak pantas dibayar.
Dua akibatnya saya sukai. Jenis diagram yang tidak dikenali ditolak dengan menyebut sebabnya dan sumbernya ditampilkan apa adanya, karena diagram yang salah gambar lebih menyesatkan daripada kode yang terbaca jujur. Dan tata letaknya dihitung sekali lalu dipakai dua kali — oleh pelukis layar dan oleh penulis PDF — sebab kalau masing-masing menghitung sendiri, diagram di layar dan di cetakan akan berbeda, dan yang mencetak tidak akan tahu mana yang benar.
Bug yang diam-diam mematikan fitur utamanya
Sebuah const Spacer() di dalam AlertDialog.actions. Flutter merender bagian itu dengan OverflowBar, yang tidak menerima Expanded, jadi setiap kali dialog itu dibangun ia melempar eksepsi.
Dialog itu adalah dialog Stabilo + komentar. Jadi fitur yang menjadi alasan seluruh aplikasi ini ada, ditambah catatan dan penyuntingan anotasi, tidak pernah bisa dipakai sama sekali. Sekarang dijaga empat widget test, termasuk satu yang memastikan tombol Simpan masih bisa diketuk saat papan tik naik.
Hal-hal yang hanya diajarkan perangkat sungguhan
Penolak telapak tangan bukan heuristik. Pengenal gerakannya diberi supportedDevices berisi stylus saja, jadi tangan yang bersandar tidak pernah sampai ke kanvas. Tetikus tetap diizinkan. Tekanan stylus dibaca dari peristiwa pointer mentah, karena pengenal gerakan tidak menyerahkannya — dan hanya untuk stylus, sebab banyak layar melaporkan tekanan tetap untuk jari.
InteractiveViewer harus dibuang, dan ongkosnya terukur. Pengenalnya bersaing memperebutkan seretan satu jari, dan akibatnya: sekitar enam puluh piksel pertama setiap goresan hilang sebelum kanvas menang di arena — dan seretan pendek, seperti satu ketukan penghapus, tidak pernah sampai sama sekali. Cubitan sekarang dihitung langsung dari peristiwa pointer.
Bug yang menyamar sebagai fitur yang tidak ada. Keluhan "catatan tidak bisa mendatar" ternyata: tombol ukuran kertasnya ada, tapi bilahnya duduk di bawah bilah navigasi sistem. Terlihat, sentuhannya diambil sistem, dan yang memakainya wajar menyimpulkan fiturnya memang tidak ada.
Pemilih berkas Android menyerahkan salinan. Bukan berkas aslinya — salinan di cache aplikasi. Jadi "simpan balik ke berkas ini" mustahil, dan menunya berbunyi jujur: Simpan PDF — pilih tempatnya sendiri.
Keadaannya sekarang
- APK-nya ditandatangani kunci debug, jadi Android memperingatkan soal sumber tidak dikenal saat memasang.
- Antarmukanya masih bahasa Indonesia saja.
- Belum ada pencarian teks di dalam PDF, belum ada daftar isi PDF, belum ada EPUB.
- Git LFS jalan di desktop, belum di Android.
- Tangkapan layar di repositori masih dari versi desktop yang lama. Video di atas yang menggambarkan keadaan sekarang.
Mengambilnya
AGPL-3.0-or-later. Silakan dipakai, diubah, dijual — tetapi siapa pun yang menerima salinannya, termasuk lewat jaringan, berhak atas sumbernya dengan syarat yang sama. Kontribusi memakai tanda tangan git commit -s, tanpa CLA.
Untuk Linux desktop, dibangun dari sumbernya. Ada pasangannya untuk separuh pekerjaan yang lain: WritePaperTeX, yang mengompilasi LaTeX di tablet yang sama.