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 |
| Need to replace text in an existing PDF (new font) | page.replace_text(old, new, font) rewrites the content stream in-place; returns the match count as usize; automatic font-switching and width compensation |
| Need to replace text using the original font | page.replace_text_preserve_font(old, new) — no FontHandle needed; returns match count; validates glyphs eagerly (not at save()) |
| Need to check replaceability without modifying | page.can_replace_text(old, new) — pure read-only scan; returns match count or Err(FontCharNotMapped) |
| Need to draw an ellipse or circle | add_ellipse(rect, color, opacity, filled, stroke_width) (draw feature) |
| Need fill + stroke on same shape | pass filled=true and stroke_width>0 to add_ellipse / add_polygon / add_path — uses PDF B operator |
| Need open or closed path (polyline + polygon unified) | add_path(points, closed, color, filled, stroke_width, opacity) (draw feature) |
| Need rotated text (watermarks, stamps at an angle) | add_text_with_rotation(text, font, pos, size, color, opacity, degrees) |
| Need to replace text spanning multiple Tj operators | replace_text / replace_text_preserve_font — cross-operator matching supported |
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.3"
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 frag in &runs
Each TextFragment carries: text, x/y (PDF-point coordinates), width, font_size, font_name (PDF resource name e.g. "HR0"), color (RGB fill [f32; 3]), and invisible (true for OCR Tr 3 text).
Works on arbitrary PDFs — Identity-H CID fonts (harumi output) and standard simple fonts (Type1, TrueType) with WinAnsiEncoding, MacRomanEncoding, StandardEncoding, or /Differences encoding dicts.
Replace text in an existing PDF
let mut doc = from_file?;
let font = doc.embed_font?;
// Returns the number of matches found (0 means old_text was not present)
let n = doc.page?.replace_text?;
doc.save?;
Matches text that spans consecutive Tj/TJ operators within the same font context (cross-operator matching). Only splits across positional operators (Td, Tm) are not matched.
Replace text using the original embedded font
When you don't have the font file but know the replacement text uses only glyphs already in the PDF.
Glyph validation is eager: Err(FontCharNotMapped) is returned immediately at call time if a glyph is missing, so you can fall back in one pass:
let mut doc = from_file?;
match doc.page?.replace_text_preserve_font
doc.save?;
Pre-flight check without modifying the document
Use can_replace_text to inspect replaceability before queuing any operations:
let mut doc = from_file?;
match doc.page?.can_replace_text
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.3", = ["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) — last arg is stroke_width (0.0 = no stroke)
doc.page?.add_polygon?;
// Filled + stroked triangle simultaneously (fill-then-stroke, PDF `B` operator)
doc.page?.add_polygon?;
// Black underline stroke
doc.page?.add_line?;
// Semi-transparent blue filled ellipse
doc.page?.add_ellipse?;
// Circle outline only (no fill, 2pt border)
doc.page?.add_ellipse?;
// Open polyline path (triangle without closing edge)
doc.page?.add_path?;
// Rotated watermark text (45° counter-clockwise)
let font = doc.embed_font?;
let = doc.page?.size?;
doc.page?.add_text_with_rotation?;
Embed images (image feature)
= { = "0.3", = ["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?;
// Replace text in existing content stream (single-operator match); returns match count
let n: usize = doc.page?.replace_text?;
// Replace using the original embedded font; eager glyph validation; returns match count
let n: usize = doc.page?.replace_text_preserve_font?;
// Read-only scan: returns match count or Err(FontCharNotMapped)
let n: usize = doc.page?.can_replace_text?;
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.2", = ["ocr"] }
Feature flags
| Flag | What it enables | Extra dependencies |
|---|---|---|
| (default) | Text overlay, font embedding, add_text_box, add_text_box_aligned, add_text_with_opacity, add_text_box_with_opacity |
lopdf, allsorts, ttf-parser |
draw |
add_rect, add_line, add_rect_stroke, add_polygon, add_polyline, add_ellipse — shapes |
none |
image |
add_image, add_image_with_opacity — JPEG/PNG raster images (enables draw) |
image crate |
ocr |
ocr::hocr_y_to_pdf, ocr::hocr_x_to_pdf, ocr::pixel_size_to_pt — Tesseract coordinate conversion |
none |
let pdf_y = hocr_y_to_pdf;
let pdf_x = hocr_x_to_pdf;
let pt = pixel_size_to_pt;
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 |
| v0.10 | replace_text — true in-stream text replacement: Tj/TJ rewrite, automatic font-switching, Td width compensation — Done |
| Next (v0.11+) | #[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