Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
**/bin/
**/obj/
**/BenchmarkDotNet.Artifacts/
**/TestResults/
.git/
.vs/
.idea/
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,9 @@ appsettings.*.json

# MCP server default output
pdf-output/
# ── BenchmarkDotNet ───────────────────────────────────────────────
BenchmarkDotNet.Artifacts/

# ── Python (tools/pdf-compare) ─────────────────────────────────────
__pycache__/
.venv/
97 changes: 97 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,103 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Performance
- PNG images are no longer decoded when composed: only the header is read, and
pixels are decoded once per distinct image when the document is saved. Placing
the same PNG 40 times is ~20× faster and allocates ~40× less. Deduplication now
keys on the original file bytes instead of hashing decoded pixels.
`VectorCanvas.GetImageSizeInPoints` no longer decodes the image either.
- Documents without page-number spans are no longer laid out a second time when
their page count has a different digit count from the initial estimate (99),
e.g. every 1–9 page document.
- Text layout computes each word's width, font, and colour once instead of 4–5
times, and memoises wrapped lines and table row heights for the duration of a
publish. Text-heavy documents are ~2× faster with about half the allocations.
- Drawing a page slice of a split table only visits that slice's cells instead
of scanning the whole table.
- Content-stream numbers and escaped text are written straight into the page
buffer without temporary strings.
- RGB and palette PNGs are embedded still compressed (`/FlateDecode` with
`/DecodeParms /Predictor 15`, palettes as an `/Indexed` lookup stream): no
decode and no re-compression. PNGs with an alpha channel are still decoded to
split out their `/SMask`.
- Consecutive words and spaces in the same font, size and colour are shown by a
single `Tj` operator instead of one positioned `Tj` per word, making sample
PDFs up to 14% smaller. Justified lines keep one positioned word at a time.
- Custom-font text with no Devanagari ि or virama skips the reordering and
conjunct-mapping pipeline when measured and encoded.
- Font subsetting builds the `glyf` table at its exact size and no longer copies
unchanged tables (≈70% fewer allocations).
- Page content streams are compressed straight from the operator buffer.
- Canvas QR codes are encoded once when recorded rather than on every draw.
- The PNG decoder reads the compressed data in place, decompresses into a single
buffer of the exact size, undoes the row filters in place, and allocates the
alpha plane only when a pixel is transparent: ~35% faster, ~70% less memory.
- Much less allocation per text block and table cell: tokens share one
per-span format object, single-line blocks reuse their token list as the line,
token lists are sized exactly, rendering contexts are structs, `PdfColor`
implements `IEquatable<PdfColor>` (colour comparisons no longer box), and
hex colours and font names are resolved without allocating. Table rows now
allocate ~8 KB instead of ~26 KB, and text-heavy documents ~80% less.
- QR code generation is 2–6× faster: the symbol is built on flat arrays, each
candidate mask is applied and undone in place instead of copying the matrix, and
the four penalty rules are scored bit-parallel on packed rows and columns. The
generated symbols are identical.
- Content-stream numbers (`F2`/`F4` coordinates and colours) are written by a
fixed-point formatter instead of the general floating-point one, with
byte-identical output: vector-heavy pages are ~35% faster.
- PNG images with an alpha channel are converted (decoded and compressed) once
per process: the resulting streams are kept in a bounded (32 MB) process-wide
cache keyed by the file's SHA-256, so a logo rendered into every document is
no longer decoded again for each one. Output is identical.
- Large documents (8 pages or more and at least 512 K characters of content)
compress their page content streams in parallel. Output is identical.
- New BenchmarkDotNet suite in `benchmarks/TerraPDF.Benchmarks`, and a throughput
harness (`benchmarks/TerraPDF.Throughput`) that measures pages per second, CPU
and memory, in or out of a container, and compares two versions (see
`docs/benchmarks.md`). In a 2-CPU / 1 GB container, with the logo cache warm,
invoices went from 356 to 10,200 pages/s and a 19-page annual report from
3,100 to 25,200 pages/s.
- Output-comparison and profiling tools in `tools/pdf-compare` (byte, visual and
glyph-position comparison, PNG round trip, font-width check against a viewer,
QR symbol reference, allocation and CPU profiling).

### Fixed
- Built-in font widths now match the Adobe AFM metrics for every WinAnsi
character. 25 entries were wrong, among them the curly quotes, bullet,
`‚ „`, `š Ž Þ ß ¡` in Helvetica, `Z`, `Ž`, `š`, `ø`, `ý`, `þ`, `ÿ` in
Times-Bold, and `„ Œ ™ œ ¡ ¦` in Times-Italic. A test now checks every entry
against the AFM files.
- Characters with no WinAnsi code (e.g. Cyrillic or CJK in a built-in font) are
measured as the `?` drawn in their place instead of a flat 500 units, so the
words after them no longer overlap.
- Line wrapping and alignment can change slightly for text containing the
characters above, because they are now measured at their real width.


---

## [Unreleased]

### Performance
- PNG images are no longer decoded when composed: only the header is read, and
pixels are decoded once per distinct image when the document is saved. Placing
the same PNG 40 times is ~20× faster and allocates ~40× less. Deduplication now
keys on the original file bytes instead of hashing decoded pixels.
`VectorCanvas.GetImageSizeInPoints` no longer decodes the image either.
- Documents without page-number spans are no longer laid out a second time when
their page count has a different digit count from the initial estimate (99),
e.g. every 1–9 page document.
- Text layout computes each word's width, font, and colour once instead of 4–5
times, and memoises wrapped lines and table row heights for the duration of a
publish. Text-heavy documents are ~2× faster with about half the allocations.
- Drawing a page slice of a split table only visits that slice's cells instead
of scanning the whole table.
- Content-stream numbers and escaped text are written straight into the page
buffer without temporary strings.
- New BenchmarkDotNet suite in `benchmarks/TerraPDF.Benchmarks` (see
`docs/benchmarks.md`).

---

## [2.3.0] - 2026-09-26
Expand Down
27 changes: 23 additions & 4 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,11 @@ started, what we expect from contributions, and how the release process works.
4. [Making Changes](#making-changes)
5. [Coding Standards](#coding-standards)
6. [Tests](#tests)
7. [Submitting a Pull Request](#submitting-a-pull-request)
8. [Reporting Bugs](#reporting-bugs)
9. [Suggesting Features](#suggesting-features)
10. [Release Process](#release-process)
7. [Benchmarks](#benchmarks)
8. [Submitting a Pull Request](#submitting-a-pull-request)
9. [Reporting Bugs](#reporting-bugs)
10. [Suggesting Features](#suggesting-features)
11. [Release Process](#release-process)

---

Expand Down Expand Up @@ -60,6 +61,8 @@ TerraPDF/
│ └── Infra/ # Public interfaces — IContainer, IDocument, IComponent
├── tests/
│ └── TerraPDF.Tests/ # xUnit test projects
├── benchmarks/
│ └── TerraPDF.Benchmarks/ # BenchmarkDotNet performance suite
├── samples/
│ └── TerraPDF.Sample/ # Six sample PDFs covering all major features
├── docs/ # Markdown documentation
Expand Down Expand Up @@ -142,6 +145,22 @@ Coverage reports (Cobertura XML) are written to `TestResults/`.

---

## Benchmarks

Performance is tracked with the BenchmarkDotNet suite in
`benchmarks/TerraPDF.Benchmarks/`:

```sh
dotnet run -c Release --project benchmarks/TerraPDF.Benchmarks -- --filter '*'
```

If your change touches layout, text, fonts, images, encryption or serialization,
run the relevant benchmarks on `master` and on your branch, and paste both result
tables (time **and** allocations) into the PR description. See
[docs/benchmarks.md](docs/benchmarks.md) for the scenarios and how to compare runs.

---

## Submitting a Pull Request

- Fill in the PR template with a clear description of what changed and why.
Expand Down
4 changes: 4 additions & 0 deletions TerraPDF.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
<Platform Name="x64" />
<Platform Name="x86" />
</Configurations>
<Folder Name="/benchmarks/">
<Project Path="benchmarks/TerraPDF.Benchmarks/TerraPDF.Benchmarks.csproj" />
<Project Path="benchmarks/TerraPDF.Throughput/TerraPDF.Throughput.csproj" />
</Folder>
<Folder Name="/samples/">
<Project Path="samples/TerraPDF.Sample/TerraPDF.Sample.csproj" />
</Folder>
Expand Down
32 changes: 32 additions & 0 deletions benchmarks/TerraPDF.Benchmarks/BarcodeBenchmarks.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using BenchmarkDotNet.Attributes;
using TerraPDF.Barcodes;
using TerraPDF.Barcodes.QrCode;
using TerraPDF.Benchmarks.Scenarios;

namespace TerraPDF.Benchmarks;

/// <summary>QR code encoding per error correction level.</summary>
public class QrCodeBenchmarks
{
private const string ShortData = "https://github.com/sahebansari/TerraPDF";
private static readonly string LongData = string.Concat(Enumerable.Repeat("TerraPDF-QR-payload-", 40));

[Params(QrErrorCorrectionLevel.L, QrErrorCorrectionLevel.H)]
public QrErrorCorrectionLevel Level { get; set; }

[Benchmark]
public int QrShort() => QrCodeGenerator.Generate(ShortData, Level).Size;

[Benchmark]
public int QrLong() => QrCodeGenerator.Generate(LongData, Level).Size;
}

/// <summary>Code128 encoding and a QR-heavy document.</summary>
public class BarcodeBenchmarks : PdfBenchmarkBase
{
[Benchmark]
public bool[] Code128() => Code128Encoder.Encode("TERRAPDF-0123456789-ABCDEF");

[Benchmark]
public long Document100QrCodes() => Publish(Docs.QrCodes(100));
}
14 changes: 14 additions & 0 deletions benchmarks/TerraPDF.Benchmarks/CanvasBenchmarks.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using BenchmarkDotNet.Attributes;
using TerraPDF.Benchmarks.Scenarios;

namespace TerraPDF.Benchmarks;

/// <summary>Vector canvas rendering: lines, dashes, pies, gradients, rotated text.</summary>
public class CanvasBenchmarks : PdfBenchmarkBase
{
[Params(10, 100)]
public int Canvases { get; set; }

[Benchmark]
public long DenseCanvas() => Publish(Docs.DenseCanvas(Canvases));
}
20 changes: 20 additions & 0 deletions benchmarks/TerraPDF.Benchmarks/EncryptionBenchmarks.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using BenchmarkDotNet.Attributes;
using TerraPDF.Benchmarks.Scenarios;
using TerraPDF.Core;

namespace TerraPDF.Benchmarks;

/// <summary>Overhead of encryption on a ~20 page text document.</summary>
public class EncryptionBenchmarks : PdfBenchmarkBase
{
private const int Pages = 20;

[Benchmark(Baseline = true)]
public long Unencrypted() => Publish(Docs.LongText(Pages));

[Benchmark]
public long Aes128() => Publish(Docs.Encrypted(Docs.LongText(Pages), EncryptionAlgorithm.Aes128));

[Benchmark]
public long Aes256() => Publish(Docs.Encrypted(Docs.LongText(Pages), EncryptionAlgorithm.Aes256));
}
23 changes: 23 additions & 0 deletions benchmarks/TerraPDF.Benchmarks/EndToEndBenchmarks.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using BenchmarkDotNet.Attributes;
using TerraPDF.Benchmarks.Scenarios;

namespace TerraPDF.Benchmarks;

/// <summary>Whole documents: compose, lay out, paginate and serialize.</summary>
public class EndToEndBenchmarks : PdfBenchmarkBase
{
private byte[] _logo = [];

[GlobalSetup]
public void Setup() => _logo = Assets.SmallLogoJpg();

/// <summary>Fixed per-document cost baseline.</summary>
[Benchmark(Baseline = true)]
public long HelloWorld() => Publish(Docs.HelloWorld());

[Benchmark]
public long Invoice30Lines() => Publish(Docs.Invoice(30, _logo));

[Benchmark]
public long Invoice300Lines() => Publish(Docs.Invoice(300, _logo));
}
41 changes: 41 additions & 0 deletions benchmarks/TerraPDF.Benchmarks/FontBenchmarks.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using BenchmarkDotNet.Attributes;
using TerraPDF.Benchmarks.Scenarios;
using TerraPDF.Drawing.TrueType;

namespace TerraPDF.Benchmarks;

/// <summary>Custom TrueType fonts: parsing, shaping, subsetting and embedding.</summary>
public class FontBenchmarks : PdfBenchmarkBase
{
private byte[] _latoBytes = [];
private byte[] _devanagariBytes = [];
private TrueTypeFont _lato = null!;
private HashSet<ushort> _latinGlyphs = [];

[GlobalSetup]
public void Setup()
{
Assets.RegisterFonts();
_latoBytes = Assets.LatoRegular();
_devanagariBytes = Assets.Devanagari();
_lato = TrueTypeFont.Parse(_latoBytes);
_latinGlyphs = [.. Text.Paragraph.Select(ch => _lato.GetGlyphId(ch))];
}

[Benchmark]
public int ParseLato() => TrueTypeFont.Parse(_latoBytes).NumGlyphs;

[Benchmark]
public int ParseDevanagari() => TrueTypeFont.Parse(_devanagariBytes).NumGlyphs;

[Benchmark]
public byte[] SubsetLato() => _lato.BuildSubsetRawData(_latinGlyphs);

/// <summary>10 pages of Latin text in an embedded, subsetted custom font.</summary>
[Benchmark]
public long LatinDocument10Pages() => Publish(Docs.LongText(10, "Lato"));

/// <summary>10 pages of Devanagari (GSUB conjuncts + reordering).</summary>
[Benchmark]
public long DevanagariDocument10Pages() => Publish(Docs.LongText(10, "Devanagari", Text.Devanagari));
}
66 changes: 66 additions & 0 deletions benchmarks/TerraPDF.Benchmarks/ImageBenchmarks.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
using BenchmarkDotNet.Attributes;
using TerraPDF.Benchmarks.Scenarios;
using TerraPDF.Drawing;

namespace TerraPDF.Benchmarks;

/// <summary>Image decoding and embedding.</summary>
public class ImageBenchmarks : PdfBenchmarkBase
{
private byte[] _png = [];
private string _pngKey = "";
private byte[] _rgbPng = [];
private byte[] _alphaPng = [];
private byte[] _jpg = [];

/// <summary>Number of placements of the same image — exposes missing de-duplication.</summary>
[Params(1, 40)]
public int Count { get; set; }

[GlobalSetup]
public void Setup()
{
_png = Assets.HeaderLogoPng();
_pngKey = ImageSource.FromBytes(_png).ContentKey;
_rgbPng = Assets.HeaderLogoRgbPng();
_alphaPng = Assets.AlphaBadgePng();
_jpg = Assets.SmallLogoJpg();
}

[Benchmark]
public int DecodePng()
{
return PngDecoder.Decode(_png, out _, out _, out _).Length;
}

[Benchmark]
public int DecodeAlphaPng()
{
return PngDecoder.Decode(_alphaPng, out _, out _, out _).Length;
}

/// <summary>
/// RGBA PNG with the converted streams already in the process-wide cache
/// (a service rendering its logo into every document).
/// </summary>
[Benchmark]
public long PngDocument() => Publish(Docs.Images(_png, Count));

/// <summary>Same document, but the image is converted again (first document of the process).</summary>
[Benchmark]
public long PngDocumentColdCache()
{
EncodedImageCache.Remove(_pngKey);
return Publish(Docs.Images(_png, Count));
}

/// <summary>Opaque RGB PNG: embedded still compressed, no decode.</summary>
[Benchmark]
public long RgbPngDocument() => Publish(Docs.Images(_rgbPng, Count));

[Benchmark]
public long AlphaPngDocument() => Publish(Docs.Images(_alphaPng, Count));

[Benchmark]
public long JpegDocument() => Publish(Docs.Images(_jpg, Count));
}
Loading
Loading