From 455e6b394c70f1dafeb4beb6f3bcb490f97a4e62 Mon Sep 17 00:00:00 2001 From: Paullo9 Date: Sat, 31 Oct 2026 12:28:04 +0100 Subject: [PATCH] feat: TIFF series the reader cannot place are refused by name (TIFFError.unsupportedSeries) A multi-page TIFF whose pages do not make one series the reader takes now throws TIFFError.unsupportedSeries(file:series:) with the file name and the kind of series: an OME-TIFF series, a pyramid of reduced-resolution pages (the reduced bit, or a page one sampling step below the first, as tifffile pyramidize_series does), N series of pages that differ in shape, type or layout, and the ImageJ and shaped stacks it cannot place. Before, these threw FormatoReadError.unsupported without the file name. Tests: TIFFSeriesRefusalTests builds multi-page TIFFs byte by byte (two shapes, a reduced pyramid, a half-size page without the bit, an OME description) and checks the typed case and its text; a uniform stack still reads. Migration: code that caught FormatoReadError.unsupported for these files catches TIFFError.unsupportedSeries instead; an exhaustive switch on TIFFError adds the case. Done: series refusals are typed and named (part of #6). Next: vendor pixel-size calibrations (TVIPS, Zeiss SEM, JEOL SightX) in their own PR. Co-Authored-By: Claude Opus 5.5 --- .../FormatoTIFF.docc/Reading TIFF files.md | 2 +- Sources/FormatoTIFF/TIFFFile.swift | 4 + Sources/FormatoTIFF/TIFFShapedSeries.swift | 11 ++- Sources/FormatoTIFF/TIFFSignals.swift | 24 +++-- .../TIFFSeriesRefusalTests.swift | 97 +++++++++++++++++++ 5 files changed, 124 insertions(+), 14 deletions(-) create mode 100644 Tests/FormatoTIFFTests/TIFFSeriesRefusalTests.swift diff --git a/Sources/FormatoTIFF/FormatoTIFF.docc/Reading TIFF files.md b/Sources/FormatoTIFF/FormatoTIFF.docc/Reading TIFF files.md index d0f7689..60417ce 100644 --- a/Sources/FormatoTIFF/FormatoTIFF.docc/Reading TIFF files.md +++ b/Sources/FormatoTIFF/FormatoTIFF.docc/Reading TIFF files.md @@ -39,4 +39,4 @@ The data is kept as stored. A colour page is one element per pixel, so an RGB pa ## What the module refuses -The reader reads uncompressed pages with strips and contiguous samples. A compressed page, or a tiled one, is refused with ``TIFFError/unsupportedLayout(_:)``, and so is a file from a vendor format that the module does not read yet. Each refusal names the reason, so no page is decoded into a wrong array. +The reader reads uncompressed pages with strips and contiguous samples. A compressed page, or a tiled one, is refused with ``TIFFError/unsupportedLayout(_:)``, and so is a file from a vendor format that the module does not read yet. A multi-page file whose pages do not make one series this reader takes (an OME-TIFF, a pyramid of reduced-resolution pages, pages of several shapes or types, an ImageJ or shaped stack it cannot place) is refused with ``TIFFError/unsupportedSeries(file:series:)``, which carries the file name and the kind of series. Each refusal names the reason, so no page is decoded into a wrong array. diff --git a/Sources/FormatoTIFF/TIFFFile.swift b/Sources/FormatoTIFF/TIFFFile.swift index 46d6d28..de4de56 100644 --- a/Sources/FormatoTIFF/TIFFFile.swift +++ b/Sources/FormatoTIFF/TIFFFile.swift @@ -64,6 +64,9 @@ public nonisolated enum TIFFError: Error, Equatable, LocalizedError { case loop(String) /// A page layout this reader refuses rather than guesses: compression, tiles, planar 2, a sample layout. case unsupportedLayout(String) + /// A series of pages this reader refuses rather than guesses, named: the file's display name and the kind of series + /// ("an OME-TIFF series", "a pyramid of reduced-resolution pages", "3 series of pages that differ in shape or type", ...). + case unsupportedSeries(file: String, series: String) public var errorDescription: String? { switch self { @@ -71,6 +74,7 @@ public nonisolated enum TIFFError: Error, Equatable, LocalizedError { case .truncated(let n): return "\(n) ends before its data does." case .loop(let n): return "\(n)'s directory chain loops." case .unsupportedLayout(let why): return why + case .unsupportedSeries(let file, let series): return "\(file) holds \(series), which this reader does not read." } } } diff --git a/Sources/FormatoTIFF/TIFFShapedSeries.swift b/Sources/FormatoTIFF/TIFFShapedSeries.swift index eaac198..833e046 100644 --- a/Sources/FormatoTIFF/TIFFShapedSeries.swift +++ b/Sources/FormatoTIFF/TIFFShapedSeries.swift @@ -136,7 +136,7 @@ extension TIFFSignals { } /// tifffile's `subresolution(a, b, p)` for series: the pyramid level of `b` below `a`, or nil. - private static func subresolution(_ a: TIFFSeries, _ b: TIFFSeries, p: Int, n: Int = 16) -> Int? { + static func subresolution(_ a: TIFFSeries, _ b: TIFFSeries, p: Int, n: Int = 16) -> Int? { guard a.axes == b.axes, a.dataType == b.dataType, a.shape.count == b.shape.count else { return nil } var level: Int? for ((ax, i), j) in zip(zip(a.axes.lowercased(), a.shape), b.shape) { @@ -171,11 +171,12 @@ extension TIFFSignals { let size = file.data.count var series: [TIFFSeries] = [] var reducedKeyframes: [Bool] = [] + let name = formatoDisplayFileName(file.path) var index = 0 while index < dirs.count { let keyDir = dirs[index] guard let text = shapedDescription(keyDir), let parsed = try parseShapedDescription(text) else { return nil } - guard keyDir[330] == nil else { throw FormatoReadError.unsupported("A shaped series with SubIFDs is not ported.") } + guard keyDir[330] == nil else { throw TIFFError.unsupportedSeries(file: name, series: "a shaped series with SubIFDs") } let key = try TIFFPageLayout.of(keyDir, bigEndian: file.bigEndian, fileSize: size) let pageShape = key.samples > 1 ? [key.height, key.width, key.samples] : [key.height, key.width] let pageAxes: [Character] = key.samples > 1 ? ["Y", "X", "S"] : ["Y", "X"] @@ -226,14 +227,14 @@ extension TIFFSignals { if let reshaped = try reshapeAxes(curAxes, curShape, outReshape, file: formatoDisplayFileName(file.path)) { curAxes = reshaped; curShape = outReshape } // The images the series reads: one per page, or (one page, data following it) a virtual run. let images = try product(curShape) / pageElements - guard try product(curShape) % pageElements == 0 else { throw FormatoReadError.unsupported("A shaped series whose shape is not a whole number of pages.") } + guard try product(curShape) % pageElements == 0 else { throw TIFFError.unsupportedSeries(file: name, series: "a shaped series whose shape is not a whole number of pages") } let layouts: [TIFFPageLayout] var virtual: Int? if members.count == images { layouts = try members.map { m in let l = try TIFFPageLayout.of(dirs[m], bigEndian: file.bigEndian, fileSize: size) guard l.width == key.width, l.height == key.height, l.dataType == key.dataType, l.samples == key.samples else { - throw FormatoReadError.unsupported("A page of a shaped series differs from its first page.") + throw TIFFError.unsupportedSeries(file: name, series: "a shaped series whose pages differ from its first page") } return l } @@ -242,7 +243,7 @@ extension TIFFSignals { guard !o, key.stripOffsets[0] <= size - bytes else { throw TIFFError.truncated("The shaped series claims more bytes than the file holds.") } layouts = [key]; virtual = images } else { - throw FormatoReadError.unsupported("A shaped series of \(images) images over \(members.count) pages that are not stored contiguously.") + throw TIFFError.unsupportedSeries(file: name, series: "a shaped series of \(images) images over \(members.count) pages that are not stored contiguously") } series.append(TIFFSeries(axes: String(curAxes), shape: curShape, dataType: key.dataType, pages: layouts, virtualCount: virtual, firstDirectory: index)) diff --git a/Sources/FormatoTIFF/TIFFSignals.swift b/Sources/FormatoTIFF/TIFFSignals.swift index aa06d8f..17f0b2a 100644 --- a/Sources/FormatoTIFF/TIFFSignals.swift +++ b/Sources/FormatoTIFF/TIFFSignals.swift @@ -238,6 +238,7 @@ public nonisolated struct TIFFSignals: Sendable { static func buildSeries(_ file: TIFFFile) throws -> [TIFFSeries] { let size = file.data.count let first = file.directories[0] + let name = formatoDisplayFileName(file.path) let layout0 = try TIFFPageLayout.of(first, bigEndian: file.bigEndian, fileSize: size) let pageAxes = layout0.samples > 1 ? "YXS" : "YX" let pageShape = layout0.samples > 1 ? [layout0.height, layout0.width, layout0.samples] : [layout0.height, layout0.width] @@ -247,7 +248,7 @@ public nonisolated struct TIFFSignals: Sendable { let shaped = shapedDescription(first) != nil if shaped, let s = try shapedSeries(file) { return s } if !shaped { - if let d = first[270]?.text, d.contains(" 1 ? "I" : "") + (l.samples > 1 ? "YXS" : "YX") let shape: [Int] = (n > 1 ? [n] : [Int]()) + (l.samples > 1 ? [l.height, l.width, l.samples] : [l.height, l.width]) return TIFFSeries(axes: axes, shape: shape, dataType: l.dataType, pages: g.pages) } + guard all.count == 1 else { + // tifffile's generic rule ends with `pyramidize_series(series)`: a later series one sampling step (2, 3 or 4) below + // the first, or a page marked reduced (NewSubfileType bit 0), names a pyramid; otherwise several series. + let pyramid = file.directories.dropFirst().contains(where: isReduced) + || all.dropFirst().contains { b in [2, 3, 4].contains { subresolution(all[0], b, p: $0) == 1 } } + if pyramid { throw TIFFError.unsupportedSeries(file: name, series: "a pyramid of reduced-resolution pages") } + throw TIFFError.unsupportedSeries(file: name, series: "\(all.count) series of pages that differ in shape, type or layout") + } + return all } /// tifffile's `series_imagej` for the files in scope; nil where it returns None (then the generic rule applies). A @@ -277,6 +284,7 @@ public nonisolated struct TIFFSignals: Sendable { /// neither the pages nor a contiguous block after the first page hold, are refused. static func imageJSeries(_ file: TIFFFile, _ meta: [String: FormatoPythonValue], _ page: TIFFPageLayout, _ pageAxes: String, _ pageShape: [Int]) throws -> TIFFSeries? { + let name = formatoDisplayFileName(file.path) func int(_ k: String) throws -> Int { switch meta[k] { case nil: return 1 @@ -325,14 +333,14 @@ public nonisolated struct TIFFSignals: Sendable { var virtual: Int? if pages == 1 || page.stripOffsets[0] + nbytes <= file.directories[1].offset { let contiguous = zip(page.stripOffsets, page.stripOffsets.dropFirst()).allSatisfy { $1 - $0 == page.rowsPerStrip * page.rowBytes } - guard contiguous else { throw FormatoReadError.unsupported("An ImageJ virtual stack whose first page's strips are not contiguous.") } + guard contiguous else { throw TIFFError.unsupportedSeries(file: name, series: "an ImageJ virtual stack whose first page's strips are not contiguous") } layouts = [page]; virtual = images } else { - guard pages >= images else { throw FormatoReadError.unsupported("The ImageJ description counts \(images) images over \(pages) pages.") } + guard pages >= images else { throw TIFFError.unsupportedSeries(file: name, series: "an ImageJ stack that counts \(images) images over \(pages) pages") } layouts = try file.directories.prefix(images).map { let l = try TIFFPageLayout.of($0, bigEndian: file.bigEndian, fileSize: file.data.count) guard l.width == page.width, l.height == page.height, l.dataType == page.dataType else { - throw FormatoReadError.unsupported("A page of the ImageJ stack differs from the first.") + throw TIFFError.unsupportedSeries(file: name, series: "an ImageJ stack whose pages differ from the first") } return l } diff --git a/Tests/FormatoTIFFTests/TIFFSeriesRefusalTests.swift b/Tests/FormatoTIFFTests/TIFFSeriesRefusalTests.swift new file mode 100644 index 0000000..50e41e1 --- /dev/null +++ b/Tests/FormatoTIFFTests/TIFFSeriesRefusalTests.swift @@ -0,0 +1,97 @@ +// +// TIFFSeriesRefusalTests.swift +// A multi-page TIFF whose pages the reader cannot make into one series is refused by name: the typed +// `TIFFError.unsupportedSeries` carries the file's name and the kind of series. Files built byte by byte here. +// + +import Foundation +import Testing +import FormatoCore +@testable import FormatoTIFF + +@Suite struct TIFFSeriesRefusalTests { + /// One uint8 page: its width, height, and NewSubfileType (nil: no tag). + private struct Page { let width: Int; let height: Int; var subfile: Int? = nil; var description: String? = nil } + + /// A little-endian classic TIFF, one IFD per page, each page one strip of `width x height` bytes (values 0, 1, 2, ...). + private func build(_ pages: [Page]) -> Data { + var d = Data([0x49, 0x49, 42, 0, 0, 0, 0, 0]) + func u16(_ v: Int) { d.append(contentsOf: [UInt8(v & 0xFF), UInt8((v >> 8) & 0xFF)]) } + func u32(_ v: Int) { d.append(contentsOf: (0..<4).map { UInt8((v >> (8 * $0)) & 0xFF) }) } + func put32(_ v: Int, at i: Int) { for k in 0..<4 { d[i + k] = UInt8((v >> (8 * k)) & 0xFF) } } + var linkAt = 4 + for page in pages { + // Pixels, then the description text, then the IFD (word aligned). + let pixelsAt = d.count + d.append(contentsOf: (0..<(page.width * page.height)).map { UInt8($0 & 0xFF) }) + var textAt = 0, text: [UInt8] = [] + if let s = page.description { text = Array(s.utf8) + [0]; textAt = d.count; d.append(contentsOf: text) } + if d.count % 2 == 1 { d.append(0) } + put32(d.count, at: linkAt) + enum Entry { case short(Int, Int), long(Int, Int), ascii(Int) } + var entries: [Entry] = [] + if let s = page.subfile { entries.append(.long(254, s)) } + entries += [.short(256, page.width), .short(257, page.height), .short(258, 8), .short(259, 1), .short(262, 1)] + if page.description != nil { entries.append(.ascii(270)) } + entries += [.long(273, pixelsAt), .short(277, 1), .short(278, page.height), .long(279, page.width * page.height)] + u16(entries.count) + for e in entries { + switch e { + case .short(let c, let v): u16(c); u16(3); u32(1); u16(v); u16(0) + case .long(let c, let v): u16(c); u16(4); u32(1); u32(v) + case .ascii(let c): u16(c); u16(2); u32(text.count); u32(textAt) + } + } + linkAt = d.count + u32(0) + } + return d + } + + private func refusal(_ pages: [Page], _ name: String) throws -> TIFFError? { + let url = FileManager.default.temporaryDirectory.appendingPathComponent("\(name)-\(UUID().uuidString).tif") + try build(pages).write(to: url) + defer { try? FileManager.default.removeItem(at: url) } + do { _ = try TIFFSignals(path: url.path); return nil } catch let e as TIFFError { return e } catch { + Issue.record("\(name): \(error) is not a TIFFError"); return nil + } + } + + @Test func testAStackOfOneShapeStillReads() throws { + let url = FileManager.default.temporaryDirectory.appendingPathComponent("stack-\(UUID().uuidString).tif") + try build([Page(width: 4, height: 3), Page(width: 4, height: 3)]).write(to: url) + defer { try? FileManager.default.removeItem(at: url) } + let tiff = try TIFFSignals(path: url.path) + #expect(tiff.series.map(\.shape) == [[2, 3, 4]]) + } + + @Test func testPagesOfTwoShapesAreRefusedByName() throws { + let error = try refusal([Page(width: 4, height: 3), Page(width: 2, height: 2), Page(width: 4, height: 3)], "two-shapes") + guard case .unsupportedSeries(let file, let series)? = error else { Issue.record("\(String(describing: error))"); return } + #expect(file.hasPrefix("two-shapes-")) + #expect(series == "2 series of pages that differ in shape, type or layout") + #expect(error?.errorDescription == "\(file) holds 2 series of pages that differ in shape, type or layout, which this reader does not read.") + } + + @Test func testAPyramidIsRefusedByName() throws { + let error = try refusal([Page(width: 8, height: 6), Page(width: 4, height: 3, subfile: 1)], "pyramid") + guard case .unsupportedSeries(let file, let series)? = error else { Issue.record("\(String(describing: error))"); return } + #expect(file.hasPrefix("pyramid-")) + #expect(series == "a pyramid of reduced-resolution pages") + } + + /// tifffile's generic rule pyramidizes without asking for the reduced bit: a page one sampling step smaller is a level. + @Test func testAPageHalfTheSizeWithoutTheReducedBitIsAPyramid() throws { + let error = try refusal([Page(width: 8, height: 6), Page(width: 4, height: 3)], "half") + guard case .unsupportedSeries(_, let series)? = error else { Issue.record("\(String(describing: error))"); return } + #expect(series == "a pyramid of reduced-resolution pages") + } + + @Test func testAnOMETIFFIsRefusedByName() throws { + let ome = "" + let error = try refusal([Page(width: 4, height: 3, description: ome), Page(width: 4, height: 3)], "ome") + guard case .unsupportedSeries(let file, let series)? = error else { Issue.record("\(String(describing: error))"); return } + #expect(file.hasPrefix("ome-")) + #expect(series == "an OME-TIFF series") + } +}