Universe

diffst renders presentable side-by-side diff reports in Typst.

#import "@preview/diffst:0.1.0": diffst

#let old-file = path("old.typ")
#let new-file = path("new.typ")

#diffst(old-file, new-file)

The package reads two text files, sends their contents to a Rust WebAssembly plugin powered by the Rust similar crate, and renders the structured diff as Typst content. If you already have strings, use diffst-content.

License

diffst’s Typst wrapper code and package-specific Rust code are licensed under the MIT License; see LICENSE-MIT. The underlying Rust similar crate used by the WebAssembly plugin is licensed under the Apache License 2.0; see LICENSE-APACHE.

Want a report for everything changed between two commits? Use the uv runnable git-diff helper from the clvnkhr/diffst source repository to generate a Typst document from a pair of Git revisions.

Quick Start

#import "@preview/diffst:0.1.0": diffst

#set page(height: auto)

#let old-file = path("draft-old.typ")
#let new-file = path("draft-new.typ")

#diffst(
  old-file,
  new-file,
  inline: "words",
)

For an unbroken review view, #set page(height: auto) lets the diff flow as one continuous page instead of splitting tables across fixed-height pages.

By default, diffst collapses long unchanged regions. Use display: "full" to show every line.

#diffst(
  old-file,
  new-file,
  display: "full",
)

diffst(old-path, new-path, ..) accepts paths; for text you already have in Typst, call diffst-content(...).

Main Options

#diffst(
  old-file,
  new-file,
  old-label: "old draft",
  new-label: "new draft",
  algorithm: "histogram",
  inline: "words",
  unicode: true,
  semantic-cleanup: true,
  ignore-whitespace: false,
  show-whitespace: false,
  display: "collapsed",
  context-lines: 3,
  collapse-threshold: 14,
  table-layout: "split",
  table-style: "default",
)
  • algorithm: "histogram", "myers", "patience", "lcs", or "hunt". Histogram is the default because it tends to produce readable changed blocks for prose and reordered code. Myers is the classic baseline.
  • inline: "words", "chars", or "none". Word mode is the default because it reads better in reports; character mode is available for very fine-grained edits.
  • unicode: when true, inline diffs use grapheme clusters and Unicode word boundaries.
  • semantic-cleanup: uses similar’s compact inline diff adapter to make inline highlights less fragmented and easier to read. It is on by default for presentation; set it to false when you want more literal token-by-token inline spans.
  • ignore-whitespace: compares lines while ignoring whitespace differences.
  • show-whitespace: makes changed spaces and tabs visible, and marks trailing spaces or tabs on otherwise unchanged lines. Marker drawings are vector overlays, so PDF copy/paste keeps the underlying whitespace.
  • diffst-content: use this instead of diffst when you want to pass strings directly. old-label and new-label control the rendered labels.
  • old-label / new-label: optional labels shown above the two sides. For diffst, these default to the paths.
  • display: "full" or "collapsed".
  • context-lines: unchanged lines to keep around collapsed regions.
  • collapse-threshold: unchanged run length required before collapsing.
  • table-layout: "split" or "single". Split is the default and uses synchronized tables so old/new content columns are easier to copy separately. Single renders one Typst table.
  • table-style: "default", "minimal", default-table-style, minimal-table-style, or a derived style dictionary.

Styling

Override colors per report or document-wide:

#import "@preview/diffst:0.1.0": diffst, diffst-style, default-colors

#show: diffst-style.with(colors: default-colors + (
  replace: rgb("#e0f2fe"),
  inline-delete: rgb("#f0abfc"),
  inline-insert: rgb("#67e8f9"),
))

#diffst(
  old-file,
  new-file,
)

Available color keys:

text, line-no, border, header, equal, delete, insert, replace, inline-delete, inline-insert, inline-equal, delete-text, insert-text, replace-text, marker, and collapsed.

For printed papers or compact reports, use the minimal style:

#import "@preview/diffst:0.1.0": diffst, minimal-table

#show: minimal-table

#diffst(
  old-file,
  new-file,
)

minimal-table sets minimal-colors and minimal-table-style. The minimal table keeps only the middle separator and the rule under the header while retaining colored inline highlights.

Table style dictionaries control columns and stroke widths:

#import "@preview/diffst:0.1.0": diffst, default-table-style

#diffst(
  old-file,
  new-file,
  table-style: default-table-style + (
    columns: (2em, 1fr, 2em, 1fr),
    stroke-width: (header: 0.7pt, body: 0.35pt),
  ),
)

Prefer #show table: set table(..) and #show table.cell: ... for table styling. Broad wrappers such as #show table: it => block(..)[#it] can affect internal measurement tables used to synchronize row heights.

Composition API

For custom reports, build from data upward:

#import "@preview/diffst:0.1.0": (
  diffst-report,
  diffst-summary,
  diffst-table,
  diffst-single-table,
)

#let report = diffst-report(
  read(path("paper-old.typ")),
  read(path("paper-new.typ")),
  old-label: "paper-old.typ",
  new-label: "paper-new.typ",
  inline: "words",
  semantic-cleanup: true,
)

#diffst-summary(report)

#v(8pt)

#diffst-table(report, range: (10, 18))

#v(8pt)

#diffst-single-table(report, range: (26, 34), range-side: "new")

The main composition layers are:

  • diffst(old-path, new-path, ..) reads files and renders the default report.
  • diffst-content(old-text, new-text, ..) renders the default report from already-read strings.
  • diffst-report(old-text, new-text, ..) returns structured diff data and metadata from already-read strings.
  • diffst-summary(report, ..) renders the file labels, line counts, and stat pills.
  • diffst-table(report, ..) renders the default split-table diff.
  • diffst-single-table(report, ..) renders the one-table version.
  • diffst-layout(report, ..) renders the default full report: summary, spacing, and table.

range: (start, end) filters to an inclusive 1-based line range before display is applied. By default, rows are kept when either the old or new line number is in range. Use range-side: "old" or range-side: "new" to anchor the range to one file.

Use diffst-layout(..., body: (report, rows, colors) => ..) when you want the default row filtering, range handling, and color resolution, but a custom final arrangement:

#diffst-layout(
  report,
  range: (10, 18),
  body: (report, rows, colors) => [
    #diffst-summary(report, colors: colors)
    #v(4pt)
    #diffst-single-table(report, rows: rows, colors: colors)
  ],
)

Lower-Level Data Helpers

Use these when you want to compute your own layout:

  • report.labels, report.options, report.meta, report.stats, report.ops, and report.rows expose the underlying diff data. The older report.old and report.new label aliases are also available.
  • diffst-rows(report, display: .., range: ..) returns renderable row dictionaries without emitting content.
  • diffst-hunks(report, context-lines: ..) returns hunk dictionaries with ops, rows, old_start, old_len, new_start, and new_len.
  • diffst-debug(report) renders the raw report fields.

The similarity score is available as report.stats.similarity, from 0.0 to 1.0.

Copy/Paste Notes

The default split layout uses separate synchronized tables for old line numbers, old content, new line numbers, and new content. This makes it easier to select one side of the diff in PDF viewers.

Long unbroken code spans are clipped to the table cell instead of being broken with inserted characters. Visually wrapped lines may still copy with inserted newlines depending on the PDF viewer; that is a PDF text-extraction behavior, not an inserted character in the Typst source.

Metadata

report.meta includes:

algorithm, inline, unicode, ignore_whitespace, show_whitespace, semantic_cleanup, old_trailing_newline, new_trailing_newline, old_line_endings, new_line_endings, and messages.

Line ending values are "lf", "crlf", "cr", "mixed", or "none". When only the final trailing newline differs, the rendered diff adds a note row; raw newline and line-ending details remain available through report.meta.

The summary’s x% similar lines score is based on exactly matched lines. A prose diff with small edits on every line can therefore show 0% similar lines even when the lines look visually similar.

Examples

Focused diff option examples live in examples/diffs/:

  • algorithms: algorithm-myers.typ, algorithm-patience.typ, algorithm-histogram.typ, algorithm-lcs.typ, algorithm-hunt.typ
  • inline modes: inline-chars.typ, inline-words.typ, inline-none.typ
  • display: display-full.typ, display-collapsed.typ, collapse-threshold.typ, context-lines.typ
  • whitespace: ignore-whitespace.typ, show-whitespace.typ, trailing-whitespace.typ, trailing-newline.typ
  • other: unicode.typ, semantic-cleanup.typ, long-lines.typ, table-layout.typ, debug.typ, hunks.typ

Source fixtures used by the examples live in examples/sources/.

Larger examples:

  • examples/diffs/basic.typ
  • examples/diffs/realistic.typ
  • examples/diffs/custom-colors.typ
  • examples/diffs/minimal-table.typ
  • examples/diffs/show-rules.typ
  • examples/diffs/manual-layout.typ
  • examples/diffs/partial-report.typ

Git Commit Diffs

The uv runnable helper scripts/git-diff.py in the clvnkhr/diffst source repository generates a Typst report for all files changed between two Git revisions. It accepts full or short commit hashes, writes one section per changed file, and adds an outline plus back-to-top links.

scripts/git-diff.py cece06a 8b48d70 --output examples/diffs/git-cece06a-8b48d70.typ
scripts/git-diff.py b8ccbd3 cece06a --output examples/diffs/git-b8ccbd3-cece06a.typ

typst compile --root . examples/diffs/git-cece06a-8b48d70.typ examples/diffs/git-cece06a-8b48d70.pdf
typst compile --root . examples/diffs/git-b8ccbd3-cece06a.typ examples/diffs/git-b8ccbd3-cece06a.pdf

Build

Contributor setup, release checks, and Typst Universe PR packaging are documented in the source repository.

rustup target add wasm32-unknown-unknown
sh scripts/smoke.sh

Development requires wasm-opt from Binaryen. The smoke script runs cargo test, builds the WASM plugin, optimizes it with wasm-opt -Oz --enable-bulk-memory, and compiles every example to ${TMPDIR:-/tmp}/diffst-smoke.

The package loads plugin.wasm from the repository root. scripts/smoke.sh refreshes that package-local artifact from the optimized release build before compiling examples.

For release checks, this package also works with community tooling:

scripts/package-pr.py
typst-package-check check --offline package-pr/packages/preview/diffst/0.1.0
(cd package-pr/packages/preview/diffst/0.1.0 && typship check)

deadline-ms is intentionally not exposed. The similar crate can use real deadlines when a clock is available, but Typst plugins do not currently provide the host clock imports needed for a reliable WASM wall-clock cutoff.