harumi
Overlay text, extract content, merge/split pages, draw shapes — all in pure Rust.
Full CJK (Japanese / Chinese / Korean) font support. Zero C dependencies. WASM-ready.
What harumi solves
Before (without harumi):
Hand-assemble CID font objects from the PDF spec. Implement CMap generation, GID mapping, and subsetting in hundreds of lines. Still fight character rendering bugs.
After (with harumi):
let mut doc = from_file?;
let font = doc.embed_font?;
doc.page?.add_invisible_text?;
doc.save?;
Font subsetting, CID encoding, and ToUnicode CMap generation are all automatic. The library handles it.
What you get
| Challenge | harumi's answer |
|---|---|
| CJK font subsetting is complex | One embed_font() call — only used glyphs are included, GIDs correctly remapped |
| Don't want to corrupt existing PDF structure | Append-only: harumi never touches the original object graph |
| Need to run in WASM / Lambda / cross-compile | Pure Rust — zero C/C++ dependencies |
| Need OCR text at specific coordinates | add_invisible_text / batch add_invisible_text_runs |
| Need to stamp a watermark on PDFs | add_text(color) overlays visible text in any RGB color |
| Need to position text relative to page size | page.size() reads the MediaBox |
| Need in-memory output for Tauri / WASM | save_to_bytes() returns a Vec<u8> directly |
| Need to draw highlight rectangles or lines | add_rect / add_line (draw feature, no extra deps) |
| Need to draw a box border or polygon (callout) | add_rect_stroke / add_polygon (draw feature) |
| Need multi-line wrapped text in a box | add_text_box (no feature gate needed) |
| Need to embed JPEG / PNG images | add_image / add_image_with_opacity (image feature) |
| Need PNG transparency (signatures, watermarks) | Transparent PNGs use PDF SMask automatically — no white background |
| Need to rotate, remove, or reorder pages | rotate_page / remove_page / insert_blank_page / reorder_pages (no feature gate) |
| Need to merge two PDFs into one | merge_from appends all pages from another document; content and fonts preserved |
| Need to create a PDF from scratch (no existing file) | Document::new(size) creates a blank 1-page PDF; add pages with insert_blank_page |
| Need to split a PDF into separate files | extract_pages returns a new Document with the specified pages in any order |
| Need to extract text positions from an existing PDF | extract_text_runs decodes CID fonts and standard simple fonts (Type1, TrueType, WinAnsi, etc.) |
| Need to read or write PDF metadata (title, author…) | doc.metadata() reads /Info; doc.set_metadata(&meta) writes it |
Why this gap existed
JS has pdf-lib — it handles font subsetting, CMap generation, and text layer composition transparently. In Rust, the existing options force you to choose between:
lopdf— low-level binary surgery; you hand-assemble CID font objects from the PDF specprintpdf— create-only; cannot modify existing PDFspdfium-render— C++ bindings that break WASM, cross-compilation, and Lambda deploys
harumi fills the gap.
Quick Start
[]
= "0.1"
Invisible OCR text layer
use ;
Visible text overlay
// Overlay a red stamp centered on the page
let = doc.page?.size?;
doc.page?.add_text?;
In-memory output
// For Tauri commands, WASM, or any in-memory pipeline
let pdf_bytes: = doc.save_to_bytes?;
Multi-line text box (no feature gate)
// Wraps at word boundaries (Latin) or any character (CJK); clips at box bottom
doc.page?.add_text_box?;
Page manipulation
// Rotate all pages 90° clockwise
for page_num in 1..=doc.page_count
// Remove a blank cover page
doc.remove_page?;
// Insert a blank A4 title page before page 1
doc.insert_blank_page?;
// Reverse page order in a 3-page document
doc.reorder_pages?;
doc.save?;
Merge PDFs
let mut base = from_file?;
let appendix = from_file?;
base.merge_from?;
base.save?;
Preserved: all page content, embedded fonts, images, resources.
Not preserved: Outlines/Bookmarks, AcroForm, /Info metadata (author, creation date).
Precondition:
othermust have no unflushed pending operations (freshly loaded, or reloaded aftersave_to_bytes()).
Create a blank PDF
let mut doc = new?; // blank A4
let font = doc.embed_font?;
doc.page?.add_text?;
doc.save?;
Extract pages
let doc = from_file?;
let mut excerpt = doc.extract_pages?; // pages 3, 5, 7 in that order
excerpt.save?;
Extract text runs from an existing PDF
let doc = from_file?;
let runs = doc.extract_text_runs?;
for fragment in &runs
Works on arbitrary PDFs — Identity-H CID fonts (harumi output) and standard simple fonts (Type1, TrueType) with WinAnsiEncoding, MacRomanEncoding, StandardEncoding, or /Differences encoding dicts.
Read/write PDF metadata
use ;
let mut doc = from_file?;
// Read existing metadata
let meta = doc.metadata?;
println!;
// Write new metadata (None fields are omitted from /Info)
doc.set_metadata?;
doc.save?;
Draw shapes (draw feature)
= { = "0.1", = ["draw"] }
// Yellow filled highlight rectangle (x, y, width, height in PDF points)
doc.page?.add_rect?;
// Blue border rectangle (stroke only, no fill)
doc.page?.add_rect_stroke?;
// Filled triangle (callout arrow tip)
doc.page?.add_polygon?;
// Black underline stroke
doc.page?.add_line?;
Embed images (image feature)
= { = "0.1", = ["image"] }
let jpeg = read?;
// Place at [x, y, width, height]; supports JPEG (no decode) and PNG
doc.page?.add_image?;
// With opacity (0.0 = transparent, 1.0 = opaque)
doc.page?.add_image_with_opacity?;
// PNG with alpha channel — transparent regions use PDF SMask, no white background
let sig_png = read?;
doc.page?.add_image?;
API Overview
// Load
let mut doc = from_file?;
let mut doc = from_bytes?;
// Font embedding (one per font file; reuse the handle across pages)
let font: FontHandle = doc.embed_font?;
// Page size (PDF points, width × height)
let = doc.page?.size?;
// Invisible text — for OCR text layers
doc.page?.add_invisible_text?;
// Visible text — for watermarks, stamps, annotations
doc.page?.add_text?;
// Batch placement (one subsetting pass — efficient for OCR output)
doc.page?.add_invisible_text_runs?;
// Page structure (no feature gate)
doc.page_count // u32
doc.rotate_page?; // multiple of 90; accumulates
doc.remove_page?; // cannot remove the last page
doc.insert_blank_page?; // after=0 prepends
doc.reorder_pages?; // 1-indexed old page numbers
doc.extract_pages?; // new Document with selected pages
// Create from scratch
new?; // blank 1-page PDF
// Merge documents (no pending ops in other)
doc.merge_from?; // append other's pages to end
// Save
doc.save?;
doc.save_to_bytes?; // in-memory variant
// Extract text from existing PDFs (CID + standard simple fonts)
let runs: = doc.extract_text_runs?;
// PDF metadata (/Info dictionary)
let meta: PdfMetadata = doc.metadata?;
doc.set_metadata?;
Coordinate system
Coordinates are in PDF points (1 pt = 1/72 inch), origin at the bottom-left of the page. If your OCR engine (e.g. Tesseract / hOCR) gives pixel coordinates from the top-left, use the ocr feature helper:
= { = "0.1", = ["ocr"] }
Feature flags
| Flag | What it enables | Extra dependencies |
|---|---|---|
| (default) | Text overlay, font embedding, add_text_box |
lopdf, allsorts, ttf-parser |
draw |
add_rect, add_line, add_rect_stroke, add_polygon — shapes |
none |
image |
add_image, add_image_with_opacity — JPEG/PNG raster images (enables draw) |
image crate |
ocr |
ocr::hocr_y_to_pdf and helpers for Tesseract coordinate conversion |
none |
let pdf_y = hocr_y_to_pdf;
let pdf_x = hocr_x_to_pdf;
Supported Fonts
| Font format | Status |
|---|---|
TrueType (.ttf, sfntVersion = 0x00010000) |
Supported |
OpenType with CFF outlines (.otf, OTTO) |
Accepted; subsetting depends on allsorts |
| TTC collections | Supported (index 0) |
For Japanese/Chinese/Korean, use the TrueType variant of Noto Sans CJK — end-to-end verified:
NotoSansCJKjp-Regular.ttf (Japanese)
NotoSansCJKsc-Regular.ttf (Simplified Chinese)
NotoSansCJKtc-Regular.ttf (Traditional Chinese)
NotoSansCJKkr-Regular.ttf (Korean)
OTF note: harumi accepts
.otffiles and routes them throughFontFile3 /OpenTypeembedding. However, allsorts v0.17 cannot subset all CFF variants (e.g. CFF2 variable fonts). If subsetting fails you will get aFontParseerror atsave()time. Use the TTF variants above for guaranteed compatibility.
Internals
harumi
├── lopdf v0.40 — parse and modify existing PDF object graph
├── allsorts v0.17+ — TrueType font subsetting (used in Prince typesetter)
└── ttf-parser — font metadata (bbox, units_per_em, ascender)
The font pipeline:
- Parse used characters → collect Unicode code points
- Map code points → original Glyph IDs via the font's
cmaptable (ttf-parser) - Subset the TTF to used glyphs only (allsorts); GIDs are compacted to 0..N
- Remap
gid_to_charand advance widths from original GIDs to the new compact GIDs - Build the CID font object graph:
Type0 → CIDFontType2 → FontDescriptor → FontFile2 - Generate a
/ToUnicodeCMap stream so viewers can copy/search the text - Append a new content stream to the page's
/Contentsarray
Subsetting is deferred: embed_font() stores the raw TTF bytes; at save() time, harumi collects all characters used across every page, subsets once per font, and writes everything in one pass.
Why "harumi"
晴海 — haru (clear sky) + umi (sea). Calm on the surface, a lot going on underneath.
Roadmap
| Version | Scope |
|---|---|
| v0.1 | TrueType fonts, invisible + visible text, batch placement, page.size(), save_to_bytes(), GID remapping fix, OTF accepted |
| v0.2 | draw feature (add_rect, add_line), image feature (add_image, add_image_with_opacity), CFF2 early error, TTC magic detection, MediaBox parent-chain traversal |
| v0.3 | add_text_box, add_rect_stroke, add_polygon; security hardening (NaN guards, double-save protection, indirect Contents array, JPEG marker parser fix, PNG overflow) |
| v0.4 | PNG true transparency (SMask) — transparent PNGs rendered without white background |
| v0.5 | add_text_with_opacity, add_text_box_aligned (VerticalAlign), add_polyline, add_text_box_with_opacity — Done |
| v0.6 | Page manipulation — rotate_page, remove_page, insert_blank_page, reorder_pages — Done |
| v0.7 | merge_from (PDF merging), remove_page correctness & orphan-object fix — Done |
| v0.8 | Document::new (blank PDF from scratch), extract_pages (page splitting) — Done |
| v0.9 | extract_text_runs (CID + standard simple fonts), PDF metadata read/write (metadata(), set_metadata(), PdfMetadata) — Done |
| Next (v0.10+) | #[non_exhaustive] on Error, MSRV declaration, WASM CI, publish to crates.io |
Contributing
Issues and PRs welcome at github.com/kent-tokyo/harumi.
The most complex part of this codebase is src/font/embed.rs — the CID font object graph construction. When reporting rendering bugs in a specific PDF viewer, include the viewer name and version in your issue.
License
MIT OR Apache-2.0