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
2 changes: 1 addition & 1 deletion Sources/FormatoTIFF/FormatoTIFF.docc/Reading TIFF files.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
4 changes: 4 additions & 0 deletions Sources/FormatoTIFF/TIFFFile.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,17 @@ 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 {
case .notTIFF(let n): return "\(n) is not a TIFF file."
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."
}
}
}
Expand Down
11 changes: 6 additions & 5 deletions Sources/FormatoTIFF/TIFFShapedSeries.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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"]
Expand Down Expand Up @@ -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
}
Expand All @@ -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))
Expand Down
24 changes: 16 additions & 8 deletions Sources/FormatoTIFF/TIFFSignals.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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("<OME") { throw FormatoReadError.unsupported("The file is an OME-TIFF; its series rule is not ported.") }
if let d = first[270]?.text, d.contains("<OME") { throw TIFFError.unsupportedSeries(file: name, series: "an OME-TIFF series") }
if let meta = imageJDescription(first), let s = try imageJSeries(file, meta, layout0, pageAxes, pageShape) {
return [s]
}
Expand All @@ -261,22 +262,29 @@ public nonisolated struct TIFFSignals: Sendable {
}
// tifffile splits pages by more than shape and type, and merges reduced-size pages into pyramids: a file whose pages
// fall in more than one group is refused rather than guessed.
guard groups.count == 1 else {
throw FormatoReadError.unsupported("The file's pages form \(groups.count) groups; several series are not ported.")
}
return groups.map { g in
let all = groups.map { g in
let n = g.pages.count, l = g.pages[0]
let axes = (n > 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
/// description whose counts are not integers, a count overflow, pages that disagree with the first, or images that
/// 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
Expand Down Expand Up @@ -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
}
Expand Down
97 changes: 97 additions & 0 deletions Tests/FormatoTIFFTests/TIFFSeriesRefusalTests.swift
Original file line number Diff line number Diff line change
@@ -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 = "<?xml version=\"1.0\"?><OME xmlns=\"http://www.openmicroscopy.org/Schemas/OME/2016-06\"></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")
}
}
Loading