Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,11 +264,12 @@ Press <kbd>⌘K</kbd> to search every tool and command, or browse the **All tool
- **Native on macOS, Windows, Linux and FreeBSD**, and **in the browser** through WebAssembly, from the same Rust codebase. Windows builds come for x64, x86 and ARM64 (Windows on ARM, no emulation); every ARM64 change is tested on ARM64 hardware in CI.
- **Private by design.** Documents never leave your machine. There's no account, no telemetry and no cloud processing.
- **Engine first.** Parsing, rendering and editing live in reusable library crates. The interface is one swappable layer on top.
- **Scriptable.** The `pdfcraft-cli` tool (see [Built for agents, too](#built-for-agents-too)) covers inspecting, rendering, extracting text, editing, combining, extracting pages and splitting. Robustness sweeps run on the same engine as the app.
- **Scriptable.** The `pdfcraft-cli` tool (see [Built for agents, too](#built-for-agents-too)) covers inspecting, rendering, extracting text, editing, combining, extracting pages and splitting. Robustness sweeps run on the same engine as the app. In kitty, Ghostty and WezTerm, `pdfcraft-cli view` shows pages right in the terminal ([guide](docs/cli-view.md)).

```sh
pdfcraft-cli info form.pdf # structure as JSON
pdfcraft-cli text paper.pdf --page 3 # reading-order text
pdfcraft-cli view paper.pdf # page through it in the terminal, like less
pdfcraft-cli edit in.pdf --rotate 1,2:90 --delete 5 --title "Q3" --out out.pdf
```

Expand Down
1 change: 1 addition & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
| 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 |

## Progress log
- **2026-10-15 (CLI, `view`):** `pdfcraft-cli view` draws PDF pages in the terminal through the kitty graphics protocol (kitty, Ghostty, WezTerm), directly, over SSH and inside tmux. More than one page opens a pager like `less`, two pages side by side when the window is wide enough. Pages are rendered ahead, so a page turn only places the next page, and a terminal on this machine takes them through temporary files, which kitty reads about five times faster than PNGs in the stream. What kitty drops past its image quota, and what tmux throws away for a hidden pane or a terminal that attaches later, is sent again. Usage: [docs/cli-view.md](docs/cli-view.md); internals and measurements: `apps/pdfcraft-cli/src/view/README.md`. Estimates unchanged.
- **2026-10-15 (M2, gap 12):** `cargo xtask text-oracle` scores reading order as well as word F1: the longest common subsequence of the two word sequences over the longer one. F1 is a bag of words and cannot see order at all -- measuring #2431, a change that moves text and nothing else, it called 26 of the 29 files whose output changed identical, where the order score separated 18 improvements from 6 regressions. Reports, does not gate; a disagreement with Poppler, not a verdict. Estimates unchanged.
- **2026-10-15 (M2, #2305 follow-up):** WinAnsiEncoding 0xA0 and 0xAD and MacRomanEncoding 0xCA extract as the ordinary space and hyphen Annex D.2 names them, in the vendored hayro tables (patch 30) as well as `crates/fonts`, so search, copy and redaction see characters that were hidden behind U+00A0 and U+00AD. Across the pdf.js corpus the soft hyphens we extract fall 117 to 13 and the non-breaking spaces 30 to 26; the rest come from `/ToUnicode`. An explicit `/Differences` name keeps its glyph list value, as pdftotext does. Estimates unchanged.
- **2026-10-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged.
Expand Down
10 changes: 7 additions & 3 deletions apps/pdfcraft-cli/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "pdfcraft-cli"
description = "PdfCraft command-line tool: inspect, render and robustness-check PDFs"
description = "PdfCraft command-line tool: inspect, render, view and robustness-check PDFs"
version.workspace = true
edition.workspace = true
license.workspace = true
Expand All @@ -23,9 +23,13 @@ pdfcraft-platform = { workspace = true }
base64 = "0.22"

[target.'cfg(unix)'.dependencies]
# `geteuid` for `ui`'s control-file owner check: safe, so no `unsafe` libc call here.
# `geteuid` for `ui`'s control-file owner check, and the terminal's size and raw mode for `view`
# (`termios`): safe, so no `unsafe` libc call here.
# Already in Cargo.lock through the GUI crates.
rustix = { version = "1", features = ["process"] }
rustix = { version = "1", features = ["process", "termios"] }

[dev-dependencies]
proptest = { workspace = true }

[build-dependencies]
# VERSIONINFO for pdfcraft-cli.exe; build.rs only uses it when targeting Windows.
Expand Down
39 changes: 36 additions & 3 deletions apps/pdfcraft-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
#[cfg(feature = "mcp")]
mod automation_args;
mod batch;
#[cfg(unix)]
mod view;

use std::io::Write;
use std::path::{Path, PathBuf};
Expand Down Expand Up @@ -58,6 +60,12 @@ const USAGE: &str = concat!(
"\
pdfcraft-cli info <file.pdf> [--password PW] document summary as JSON
pdfcraft-cli render <file.pdf> --page N [--dpi 96] --out x.png (.png, .jpg, .tif or .pam)
pdfcraft-cli view <file.pdf> [--pages 1,3-5] [--fit page|width] [--pager | --no-pager]
[--compression auto|fast|small] [--protocol auto|kitty] [--password PW]
show pages in this terminal: kitty, Ghostty, WezTerm
(kitty graphics protocol, tmux too; macOS, Linux and FreeBSD);
more than a page opens a pager, two pages side by side when
the window is wide enough
pdfcraft-cli text <file.pdf> [--page N] extracted text (pages separated by form feeds)
pdfcraft-cli edit <in.pdf> --out out.pdf [--rotate 1,3:90] [--delete 2,4] [--move 5:1]
[--insert-blank 1] [--title T] [--author A] [--full]
Expand Down Expand Up @@ -94,6 +102,10 @@ fn main() -> ExitCode {
None => Err(format!("usage:\n{USAGE}").into()),
Some("info") => info(&args[1..]),
Some("render") => render(&args[1..]),
#[cfg(unix)]
Some("view") => view::run(&args[1..]),
#[cfg(not(unix))]
Some("view") => Err("view: needs a Unix terminal (macOS, Linux or FreeBSD); `render --out page.png` writes an image file".into()),
Some("text") => text(&args[1..]),
Some("edit") => edit(&args[1..]),
Some("combine") => combine(&args[1..]),
Expand Down Expand Up @@ -392,7 +404,8 @@ fn text(args: &[String]) -> Result<(), CliError> {
}
}

/// The most pages one `A-B` range may name: a bound on what a typed range can allocate.
/// The most pages one `A-B` range, or a whole list, may name: a bound on what a typed list can
/// allocate (repeating a range must not multiply it).
const MAX_RANGE: usize = 1_000_000;

/// 1-based pages ("1,3,5-8": single pages and inclusive ranges, in the order given) → 0-based
Expand All @@ -410,8 +423,12 @@ fn page_list(s: &str) -> Result<Vec<usize>, String> {
if to - from >= MAX_RANGE {
return Err(format!("bad page range {item:?}: more than {MAX_RANGE} pages"));
}
if out.len().saturating_add(to - from + 1) > MAX_RANGE {
return Err(format!("bad page list: more than {MAX_RANGE} pages in all"));
}
out.extend(from - 1..to);
}
None if out.len() >= MAX_RANGE => return Err(format!("bad page list: more than {MAX_RANGE} pages in all")),
None => out.push(number(item, item)? - 1),
}
}
Expand Down Expand Up @@ -570,13 +587,19 @@ fn split(args: &[String]) -> Result<(), CliError> {
Ok(())
}

/// `render` always writes a file; where `view` exists, the error points to it for the terminal.
#[cfg(unix)]
const RENDER_MISSING_OUT: &str = "render: missing --out (.png, .jpg, .tif or .pam); to show the page in this terminal, use `pdfcraft-cli view`";
#[cfg(not(unix))]
const RENDER_MISSING_OUT: &str = "render: missing --out (.png, .jpg, .tif or .pam)";

fn render(args: &[String]) -> Result<(), CliError> {
validate_options("render", args, RENDER_OPTIONS)?;
let path = *positional(args).first().ok_or("render: missing file")?;
let page: usize = flag(args, "--page").unwrap_or("1").parse().map_err(|_| "bad --page")?;
let page_index = page.checked_sub(1).ok_or("--page must be at least 1")?;
let dpi: f32 = flag(args, "--dpi").unwrap_or("96").parse().map_err(|_| "bad --dpi")?;
let out = flag(args, "--out").ok_or("render: missing --out (.png, .jpg, .tif or .pam)")?;
let out = flag(args, "--out").ok_or(RENDER_MISSING_OUT)?;
// The file is what its name says (#248): a `.png` used to get a netpbm PAM stream.
let format = match Path::new(out).extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).as_deref() {
Some("png") => Some(ImageFormat::Png),
Expand Down Expand Up @@ -1063,7 +1086,17 @@ fn current_uid() -> u32 {

#[cfg(test)]
mod tests {
use super::control_file_problem;
use super::{MAX_RANGE, control_file_problem, page_list};

#[test]
fn a_page_list_is_bounded_in_all_not_just_per_range() {
assert_eq!(page_list("1,3-5"), Ok(vec![0, 2, 3, 4]));
let full = format!("1-{MAX_RANGE}");
assert_eq!(page_list(&full).map(|pages| pages.len()), Ok(MAX_RANGE));
// Repeating a range that is allowed on its own would multiply what it allocates.
assert!(page_list(&format!("{full},{full}")).is_err_and(|e| e.contains("in all")), "two full ranges");
assert!(page_list(&format!("{full},7")).is_err_and(|e| e.contains("in all")), "a full range and a page");
}

#[test]
fn a_control_file_must_be_yours_and_private() {
Expand Down
Loading