diff --git a/Sources/Rostrum/OPC/ContentTypes.swift b/Sources/Rostrum/OPC/ContentTypes.swift index 599e439a..ba07cdd6 100644 --- a/Sources/Rostrum/OPC/ContentTypes.swift +++ b/Sources/Rostrum/OPC/ContentTypes.swift @@ -44,11 +44,19 @@ public struct ContentTypesMap { overrides[partName] = nil } + /// Override first, then extension Default. Nil when the package declares + /// neither — which is malformed, but survivable; see + /// `OPCPackage.untypedEntries`. + public func declaredContentType(for partName: PackURI) -> String? { + overrides[partName] ?? defaults[partName.ext] + } + /// Override first, then extension Default. public func contentType(for partName: PackURI) throws -> String { - if let ct = overrides[partName] { return ct } - if let ct = defaults[partName.ext] { return ct } - throw RostrumError.packageInvalid("no content type for part \(partName)") + guard let ct = declaredContentType(for: partName) else { + throw RostrumError.packageInvalid("no content type for part \(partName)") + } + return ct } public static func parse(_ data: Data) throws -> ContentTypesMap { diff --git a/Sources/Rostrum/OPC/OPCPackage.swift b/Sources/Rostrum/OPC/OPCPackage.swift index a7098d91..3c379903 100644 --- a/Sources/Rostrum/OPC/OPCPackage.swift +++ b/Sources/Rostrum/OPC/OPCPackage.swift @@ -94,6 +94,22 @@ public final class OPCPackage { /// read, not modelled, and previously not written back. private(set) var orphanRelationshipStreams: [(name: String, data: Data)] = [] + /// Entries with no declared content type. + /// + /// `[Content_Types].xml` is required to cover every part (OPC M1.2), and a + /// package that breaks that rule is malformed. But PowerPoint itself ships + /// them: deleting content can leave a `/[trash]/0000.dat` behind in the + /// archive with no Override and no matching Default, and PowerPoint reopens + /// its own files perfectly happily. Refusing the whole deck over a part + /// nothing references cost 12 of 471 real decks in one library — 2.5%, + /// every one of which opens in PowerPoint and in python-pptx. + /// + /// So they are carried, not modelled and not rejected: same treatment as an + /// orphan `.rels` stream, and for the same reason. They cannot become + /// `Part`s — a `Part` without a content type has no legal serialization — + /// and dropping them would break the round trip. + private(set) var untypedEntries: [(name: String, data: Data)] = [] + /// Diagnostics from `read`: carried entries (directory placeholders, /// orphan `.rels` streams) that could not be decoded and were dropped. /// Opening must survive them — they are not parts, and failing the whole @@ -169,7 +185,16 @@ public final class OPCPackage { } let uri = PackURI("/" + name) let blob = try zip.data(forEntry: name) - let ct = try package.contentTypes.contentType(for: uri) + guard let ct = package.contentTypes.declaredContentType(for: uri) else { + // Not a part — nothing can reference it, because a relationship + // target without a content type could not be loaded either. + // Carried verbatim so the resave stays a fixed point. + package.untypedEntries.append((name, blob)) + package.readWarnings.append( + "part \"\(name)\" has no declared content type and is carried " + + "through unmodelled") + continue + } package.parts[uri] = Part(uri: uri, contentType: ct, blob: blob) } @@ -314,6 +339,10 @@ public final class OPCPackage { where !derived.contains(entry.name) { zip.addFile(name: entry.name, data: entry.data) } + for entry in untypedEntries.sorted(by: { $0.name < $1.name }) + where !derived.contains(entry.name) { + zip.addFile(name: entry.name, data: entry.data) + } for entry in directoryEntries.sorted(by: { $0.name < $1.name }) where !derived.contains(entry.name) { // Compress like any other entry. A placeholder is normally empty, diff --git a/Sources/Rostrum/Presentation/SVGRenderer.swift b/Sources/Rostrum/Presentation/SVGRenderer.swift index 8c938f99..4fdd3383 100644 --- a/Sources/Rostrum/Presentation/SVGRenderer.swift +++ b/Sources/Rostrum/Presentation/SVGRenderer.swift @@ -255,6 +255,7 @@ struct SVGRenderer { let x: Int, baseline: Int, size: Int let fill: String, anchor: String, text: String let bold: Bool + let typeface: String? } var lines: [Line] = [] var cursorY = 0 @@ -309,7 +310,8 @@ struct SVGRenderer { let ascent = Int((metrics.ascent(pointSize: sizePt) * Double(emuPerPoint)).rounded()) for line in wrapped { lines.append(Line(x: lineX, baseline: cursorY + ascent, size: sizeEMU, - fill: color, anchor: textAnchor, text: line, bold: bold)) + fill: color, anchor: textAnchor, text: line, bold: bold, + typeface: typeface)) cursorY += lineH } } else { @@ -320,7 +322,8 @@ struct SVGRenderer { for line in wrapEstimated(text, width: w, sizeEMU: sizeEMU) { cursorY += sizeEMU lines.append(Line(x: anchorX, baseline: cursorY, size: sizeEMU, - fill: color, anchor: textAnchor, text: line, bold: bold)) + fill: color, anchor: textAnchor, text: line, bold: bold, + typeface: typeface)) cursorY += sizeEMU / 3 } } @@ -337,13 +340,63 @@ struct SVGRenderer { default: offsetY = y } return lines.map { line in - "" - + escape(line.text) + "" + textElement(line.text, x: line.x, baseline: line.baseline + offsetY, + sizeEMU: line.size, fill: line.fill, anchor: line.anchor, + bold: line.bold, typeface: line.typeface) }.joined() } + + // MARK: - Text emission + + /// One ``, positioned in EMU but sized in points. + /// + /// The obvious markup — `font-size` in EMU, like every other length here — + /// is silently unreadable in a browser. WebKit and Blink clamp computed + /// `font-size` to a five-digit maximum *before* the viewBox transform + /// shrinks it, so a 68pt title asking for `font-size="863600"` gets clamped + /// and then scaled down to roughly one pixel. The text is present, in the + /// right place, and invisible. + /// + /// So the glyphs are specified in points, under their own + /// `translate(x, y) scale(emuPerPoint)`: the size never approaches the + /// clamp, and the scale puts it back into EMU space. `text-anchor` still + /// works — it anchors at x = 0 of the scaled space, which the translate has + /// already put at the anchor point. + private func textElement(_ text: String, x: Int, baseline: Int, sizeEMU: Int, + fill: String, anchor: String, bold: Bool, + typeface: String?) -> String { + "" + + escape(text) + "" + } + + /// The typeface the run resolved to, as a `font-family` the viewer can use. + /// + /// Without this every deck renders in the viewer's default serif, whatever + /// its brand font is — the renderer already resolves the typeface to pick + /// wrapping metrics, it just never said so in the markup. A generic + /// fallback keeps a missing font from landing back on serif by accident. + private func fontFamilyAttr(_ typeface: String?) -> String { + guard let typeface, !typeface.isEmpty else { return "" } + return " font-family=\"\(escape(typeface)), sans-serif\"" + } + + /// EMU as points, formatted deterministically. + /// + /// Run sizes come from `a:rPr/@sz` in hundredths of a point, so this is at + /// most two decimals; trailing zeros are trimmed so whole sizes stay whole + /// and byte-identical output survives. + private func points(_ emu: Int) -> String { + let hundredths = emu * 100 / emuPerPoint + let whole = hundredths / 100, frac = abs(hundredths % 100) + if frac == 0 { return String(whole) } + if frac % 10 == 0 { return "\(whole).\(frac / 10)" } + return String(format: "%d.%02d", whole, frac) + } + /// The typeface a run renders in: its own `a:latin`, the theme font it /// names indirectly (`+mj-lt`/`+mn-lt`), or — when it names none — the /// first theme font the deck has metrics for. @@ -500,8 +553,9 @@ struct SVGRenderer { else if uri == GraphicDataURI.ole { label = "[embedded object]" } else { label = "[object]" } return box(x, y, w, h, fill: "#F2F2F2", stroke: " stroke=\"#CCCCCC\" stroke-width=\"6350\"") - + "\(escape(label))" + + textElement(label, x: x + w / 2, baseline: y + h / 2, + sizeEMU: 18 * emuPerPoint, fill: "#999999", anchor: "middle", + bold: false, typeface: nil) } // MARK: - Charts @@ -550,9 +604,10 @@ struct SVGRenderer { var out = "" if let title { - out += "" - + escape(clipLabel(title, width: w, sizeEMU: 13 * emuPerPoint)) + "" + out += textElement(clipLabel(title, width: w, sizeEMU: 13 * emuPerPoint), + x: coord(fx + fw / 2), baseline: coord(fy + fh * 0.11), + sizeEMU: 13 * emuPerPoint, fill: "#666666", anchor: "middle", + bold: false, typeface: nil) } switch kind { @@ -776,9 +831,11 @@ struct SVGRenderer { let size = 10 * emuPerPoint var out = "" for (index, label) in categories.prefix(catCount).enumerated() { - out += "" + escape(clipLabel(label, width: coord(step), sizeEMU: size)) + "" + out += textElement(clipLabel(label, width: coord(step), sizeEMU: size), + x: coord(plotX + step * (Double(index) + 0.5)), + baseline: coord(baseY + height * 0.06), + sizeEMU: size, fill: "#808080", anchor: "middle", + bold: false, typeface: nil) } return out } @@ -796,9 +853,11 @@ struct SVGRenderer { let left = x + slot * Double(slotIndex) + slot * 0.1 out += box(coord(left), coord(y + height * 0.02), coord(swatch), coord(swatch), fill: seriesColor(entry.offset)) - out += "" - + escape(clipLabel(entry.element, width: coord(slot * 0.75), sizeEMU: size)) + "" + out += textElement(clipLabel(entry.element, width: coord(slot * 0.75), sizeEMU: size), + x: coord(left + swatch * 1.5), + baseline: coord(y + height * 0.02 + swatch * 0.85), + sizeEMU: size, fill: "#808080", anchor: "start", + bold: false, typeface: nil) } return out } diff --git a/Tests/RostrumTests/SVGRendererTests.swift b/Tests/RostrumTests/SVGRendererTests.swift index 01e0c0e9..2c97a9ca 100644 --- a/Tests/RostrumTests/SVGRendererTests.swift +++ b/Tests/RostrumTests/SVGRendererTests.swift @@ -355,9 +355,63 @@ import Testing #expect(svg.contains("Inherited")) #expect(svg.contains("#FF0000"), "the run fell back to the renderer's default colour") - #expect(svg.contains("\(80 * 12700)"), "the run fell back to the renderer's default size") + #expect(svg.contains("font-size=\"80\""), "the run fell back to the renderer's default size") // The layout puts ctrTitle at x=1524000; a shape rendered without - // inheritance lands at 0. - #expect(!svg.contains(" [String] { + guard let re = try? NSRegularExpression(pattern: pattern) else { return [] } + let ns = text as NSString + return re.matches(in: text, range: NSRange(location: 0, length: ns.length)) + .compactMap { $0.numberOfRanges > 1 ? ns.substring(with: $0.range(at: 1)) : nil } } } diff --git a/Tests/RostrumTests/UntypedEntryTests.swift b/Tests/RostrumTests/UntypedEntryTests.swift new file mode 100644 index 00000000..9474c2bb --- /dev/null +++ b/Tests/RostrumTests/UntypedEntryTests.swift @@ -0,0 +1,83 @@ +import Foundation +import Testing +@testable import Rostrum + +/// A part with no declared content type is malformed by OPC M1.2 — and +/// PowerPoint writes them anyway. Deleting content can leave a `/[trash]/…` +/// entry in the archive with neither an Override nor a matching Default, and +/// PowerPoint reopens its own file without complaint. +/// +/// Rejecting the whole package over one cost 12 of 471 real decks in a single +/// library (2.5%), every one of which opens in PowerPoint and in python-pptx. +/// So they are carried rather than rejected — and carried means they survive a +/// resave, because losing them would trade one bug for a quieter one. +@Suite struct UntypedEntryTests { + /// A minimal deck plus one entry nothing declares a type for. + private func deckWithTrash(_ trashName: String = "[trash]/0000.dat", + payload: Data = Data([0xDE, 0xAD, 0xBE, 0xEF])) throws -> Data { + let original = try MinimalTemplate.makePackage().serialize() + let reader = try ZipReader(data: original) + + var zip = ZipWriter() + for name in reader.entryNames { + zip.addFile(name: name, data: try reader.data(forEntry: name)) + } + zip.addFile(name: trashName, data: payload) + return try zip.finalize() + } + + @Test func aPartWithNoContentTypeDoesNotSinkThePackage() throws { + let package = try OPCPackage.read(data: try deckWithTrash()) + + #expect(package.parts[PackURI("/[trash]/0000.dat")] == nil, + "an untyped entry must not become a Part — it has no legal serialization") + #expect(package.untypedEntries.contains { $0.name == "[trash]/0000.dat" }) + } + + @Test func theDeckItselfOpensAndIsUsable() throws { + let deck = try Presentation(data: try deckWithTrash()) + #expect(deck.slides.count == 1, "the real content survived the malformed entry") + } + + /// Silence here would be the worse bug: the package is malformed, the + /// reader coped, and a caller checking round-trip fidelity deserves to know. + @Test func carryingIsRecordedNotSilent() throws { + let deck = try Presentation(data: try deckWithTrash()) + #expect(deck.readWarnings.contains { $0.contains("[trash]/0000.dat") }) + } + + /// Lossless round-tripping is the library's standing rule, and a carried + /// entry is exactly the kind of thing a resave quietly drops. + @Test func aCarriedEntrySurvivesAResave() throws { + let payload = Data("not a part".utf8) + let package = try OPCPackage.read(data: try deckWithTrash(payload: payload)) + let resaved = try OPCPackage.read(data: try package.serialize()) + + let carried = resaved.untypedEntries.first { $0.name == "[trash]/0000.dat" } + #expect(carried?.data == payload, "the bytes came back changed or not at all") + } + + @Test func resavingTwiceIsAFixedPoint() throws { + let once = try OPCPackage.read(data: try deckWithTrash()).serialize() + let twice = try OPCPackage.read(data: once).serialize() + #expect(once == twice, "determinism must survive the carried entry") + } + + /// The guard is "no declared content type", not "lives in [trash]" — the + /// rule is about the declaration, and other producers leave other names. + @Test func theRuleIsAboutTheDeclarationNotTheName() throws { + let package = try OPCPackage.read( + data: try deckWithTrash("ppt/leftovers/stray.bin")) + #expect(package.untypedEntries.contains { $0.name == "ppt/leftovers/stray.bin" }) + } + + /// An entry whose extension *is* declared is still a real part; carrying + /// must not become a way for content to go missing. + @Test func aDeclaredExtensionIsStillAPart() throws { + let package = try OPCPackage.read(data: try deckWithTrash("ppt/extra.xml")) + + #expect(package.parts[PackURI("/ppt/extra.xml")] != nil, + "the Default for \"xml\" covers this — it is a part, not a stray") + #expect(!package.untypedEntries.contains { $0.name == "ppt/extra.xml" }) + } +}