Skip to content

DW-39: Support TIFF files larger than 2 GB via page-by-page streaming#151

Open
Sawraz-IS wants to merge 2 commits into
developfrom
DW-39-support-large-tiff-streaming
Open

DW-39: Support TIFF files larger than 2 GB via page-by-page streaming#151
Sawraz-IS wants to merge 2 commits into
developfrom
DW-39-support-large-tiff-streaming

Conversation

@Sawraz-IS

@Sawraz-IS Sawraz-IS commented Jun 15, 2026

Copy link
Copy Markdown
Collaborator

DW-39: Native support for TIFF files larger than 2 GB

Problem

AnyBitmap loaded every file through File.ReadAllBytes, which is capped at ~2 GB by .NET's 32-bit array index. TIFF files above that size could not be opened at all — the load failed before any decoding could happen. Customers had to split large TIFFs externally before processing.

Change

Add a streaming loader that reads the file with a FileStream and decodes one TIFF directory (page) at a time via LibTiff (already referenced), so the whole file is never materialised as a single byte[].

  • Refactored the TIFF frame decode into a stream-agnostic ReadTiffFrames(Tiff) shared by both the in-memory and streaming paths.
  • New AnyBitmap.FromTiffFile(string) streams a TIFF from disk page-by-page.
  • FromFile / the string constructors auto-route TIFF files above the in-memory size limit to the streaming loader, and raise a clear, actionable exception for oversized non-TIFF formats instead of the opaque .NET array-size error.
  • Detect BigTIFF (version 43 / 0x2B) in addition to classic TIFF (0x2A), in both little- and big-endian — large multi-gigabyte TIFFs use the BigTIFF layout.
  • Guard against a single page whose decoded pixels would exceed the single-buffer limit.

The file size is no longer the constraint; only an individual page must fit within one decode buffer.

Validation

  • Project builds; 10 TIFF unit tests pass (existing + 3 new FromTiffFile_* tests).
  • Verified end-to-end against a real 3.8 GB / 1200-page BigTIFF: all 1200 pages stream and decode, with peak managed heap ~184 MB (never near the 2 GB wall).

Note for reviewers

AnyBitmap materialises every frame it holds, so loading all pages of a very large multi-page TIFF at once still scales total RAM with page count × page size. The hard single-buffer (int-indexed array) limit — the actual blocker — is removed; per-page memory stays bounded.

@Sawraz-IS Sawraz-IS marked this pull request as ready for review June 15, 2026 08:00

@mee-ironsoftware mee-ironsoftware left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving — the approach is correct and appropriately minimal (swap the oversized byte[] for a FileStream; the extracted ReadTiffFrames is a genuine cleanup), and the paths that matter for the use case (SaveAs / GetAllFrames / FrameCount) work. Verdict: fix-then-ship — a few seams around the streaming path should be addressed or documented first. No blockers. Details inline.

// Hold the decoded pages directly. Binary is deliberately NOT set: the
// source file is larger than a single byte[] can hold, so it is
// re-encoded on demand if the raw bytes are ever requested.
_lazyImage = new Lazy<IReadOnlyList<Image>>(() => frames);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Major: The streaming path deliberately never sets Binary, but the Binary getter, when _binary == null, re-encodes on demand from GetFirstInternalImage() (frame 0 only) using the no-arg default encoder — which is BMP (GetDefaultImageEncoderBmpEncoder). Consequences for any AnyBitmap produced here: GetBytes(), GetStream(), Length, ToString(), GetHashCode() return a single-page BMP (not the document, no exception), and Format/GetImageFormat() report Bmp where FromFile on the same file reports Tiff. FromTiffFile is public and documented as usable on normal files, so this is a silent divergence there. SaveAs(".tif") is fine (it hits the TiffEncoder/InternalSaveAsMultiPageTiff branch, not the shortcut). Suggest: document this on FromTiffFile, or re-encode multi-frame as multi-page TIFF here (mirror the SaveAs branch) / throw a clear "raw bytes unavailable for streamed TIFF" rather than returning a misleading page 0. Add a test asserting content/GetImageFormat(), not just FrameCount+dims.

List<Image> images = new();
// open a TIFF stored in the stream
using (Tiff tiff = Tiff.ClientOpen("in-memory", "r", tiffStream, new TiffStream()))
short num = tiff.NumberOfDirectories();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Major (edge): short num = tiff.NumberOfDirectories() with for (short i ...) caps the loop at 32,767 pages. A TIFF with more directories overflows negative, the loop never runs, frames.Count == 0, and LoadLargeTiffFromFile throws "contained no decodable image pages" — a misleading error for exactly the huge multi-page files this feature targets (the 3.8 GB validation file had only 1,200 pages, so this was never exercised). LibTiff.NET's SetDirectory(short) is short-typed upstream, but this should be detected and reported, not silently mis-labeled. Suggest iterating with do { ... } while (tiff.ReadDirectory()) (index as int), or explicitly throwing an actionable message when the directory count exceeds short.MaxValue.

// therefore File.ReadAllBytes) cannot exceed ~2 GB. Files above this
// threshold are routed to a streaming loader for TIFF, or rejected with a
// clear message for formats that have no page-based streaming decoder.
private const long MaxInMemoryFileBytes = 2_000_000_000L;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: MaxInMemoryFileBytes = 2_000_000_000, but File.ReadAllBytes actually succeeds up to Array.MaxLength (~2,147,483,591). A non-TIFF file between those two values now throws NotSupportedException even though the old code loaded it — a silent capability reduction (rare, and the safe direction, but unflagged). Suggest setting the constant to the real Array.MaxLength, or commenting that the gap is an intentional safety margin.

if (IsTiffFile(file))
{
// Stream the TIFF page-by-page; never materialise the whole file.
LoadLargeTiffFromFile(file);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: preserveOriginalFormat is dropped on the streaming route — FromFile(bigTiff, preserveOriginalFormat: false) is ignored, and streamed pages are always decoded RGBA. Likely immaterial for TIFF, but it's a silent inconsistency between the two routes of the same overload. Thread the flag through, or note in the remarks that it doesn't apply to the streaming path.

var (width, height, horizontalResolution, verticalResolution) = SetWidthHeight(tiff, i, ref imageWidth, ref imageHeight, ref imageXResolution, ref imageYResolution);
// A single page is still decoded into one RGBA buffer, so its pixel
// count is bounded by the .NET single-array index limit. Multi-page
// files of any total size are fine as long as each page fits.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: These comments (and the ReadTiffFrames remarks) say "the whole file is never buffered" / "per-page memory stays bounded", but all decoded pages are retained simultaneously in the returned List<Image> and held in _lazyImage. Decode is one page at a time; retention is all-pages, so total footprint still scales with page count × decoded page size. The PR body is honest about this — the code comments should match it. (The "~184 MB peak managed heap" figure for 1,200 retained RGBA pages is also likely understated, since ImageSharp pixel buffers are pooled/unmanaged and not counted in the managed heap.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants