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
14 changes: 11 additions & 3 deletions Sources/Rostrum/OPC/ContentTypes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
31 changes: 30 additions & 1 deletion Sources/Rostrum/OPC/OPCPackage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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,
Expand Down
93 changes: 76 additions & 17 deletions Sources/Rostrum/Presentation/SVGRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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
}
}
Expand All @@ -337,13 +340,63 @@ struct SVGRenderer {
default: offsetY = y
}
return lines.map { line in
"<text x=\"\(line.x)\" y=\"\(line.baseline + offsetY)\" font-size=\"\(line.size)\" "
+ "fill=\"\(line.fill)\" text-anchor=\"\(line.anchor)\""
+ (line.bold ? " font-weight=\"bold\"" : "") + ">"
+ escape(line.text) + "</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 `<text>`, 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 {
"<text transform=\"translate(\(x),\(baseline)) scale(\(emuPerPoint))\" "
+ "font-size=\"\(points(sizeEMU))\" fill=\"\(fill)\" text-anchor=\"\(anchor)\""
+ fontFamilyAttr(typeface)
+ (bold ? " font-weight=\"bold\"" : "") + ">"
+ escape(text) + "</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.
Expand Down Expand Up @@ -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\"")
+ "<text x=\"\(x + w / 2)\" y=\"\(y + h / 2)\" font-size=\"\(18 * emuPerPoint)\" fill=\"#999999\" "
+ "text-anchor=\"middle\">\(escape(label))</text>"
+ textElement(label, x: x + w / 2, baseline: y + h / 2,
sizeEMU: 18 * emuPerPoint, fill: "#999999", anchor: "middle",
bold: false, typeface: nil)
}

// MARK: - Charts
Expand Down Expand Up @@ -550,9 +604,10 @@ struct SVGRenderer {

var out = ""
if let title {
out += "<text x=\"\(coord(fx + fw / 2))\" y=\"\(coord(fy + fh * 0.11))\" "
+ "font-size=\"\(13 * emuPerPoint)\" fill=\"#666666\" text-anchor=\"middle\">"
+ escape(clipLabel(title, width: w, sizeEMU: 13 * emuPerPoint)) + "</text>"
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 {
Expand Down Expand Up @@ -776,9 +831,11 @@ struct SVGRenderer {
let size = 10 * emuPerPoint
var out = ""
for (index, label) in categories.prefix(catCount).enumerated() {
out += "<text x=\"\(coord(plotX + step * (Double(index) + 0.5)))\" "
+ "y=\"\(coord(baseY + height * 0.06))\" font-size=\"\(size)\" fill=\"#808080\" "
+ "text-anchor=\"middle\">" + escape(clipLabel(label, width: coord(step), sizeEMU: size)) + "</text>"
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
}
Expand All @@ -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 += "<text x=\"\(coord(left + swatch * 1.5))\" y=\"\(coord(y + height * 0.02 + swatch * 0.85))\" "
+ "font-size=\"\(size)\" fill=\"#808080\" text-anchor=\"start\">"
+ escape(clipLabel(entry.element, width: coord(slot * 0.75), sizeEMU: size)) + "</text>"
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
}
Expand Down
60 changes: 57 additions & 3 deletions Tests/RostrumTests/SVGRendererTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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("<text x=\"0\""), "the placeholder rendered at the origin")
// inheritance lands at 0. Text is positioned by `transform`, so the
// origin case reads `translate(0,` — checking for an `x="0"` attribute
// here would pass without testing anything.
#expect(!svg.contains("translate(0,"), "the placeholder rendered at the origin")
}

/// Browsers clamp computed `font-size` to a five-digit maximum before the
/// viewBox transform is applied, so a size emitted in EMU (a 68pt title is
/// 863600) is clamped and then scaled down to about a pixel: text present,
/// correctly placed, and invisible. Sizes therefore go out in points, under
/// a per-text `scale(12700)` that restores EMU space.
@Test func textIsSizedInPointsSoBrowsersDoNotClampItAway() throws {
let deck = try Presentation()
let slide = try deck.slides[0]
let shape = try slide.shapes.addTextBox(Rect(x: EMU(914_400), y: EMU(914_400),
width: EMU(5_486_400), height: EMU(1_828_800)))
let frame = try #require(shape.textFrame)
frame.text = "Sized"

let svg = try deck.renderSVG(slideAt: 0)

let sizes = matches(of: "font-size=\"([0-9.]+)\"", in: svg)
#expect(!sizes.isEmpty, "nothing was rendered to size")
for size in sizes {
let value = try #require(Double(size))
#expect(value < 10_000,
"font-size \(value) is in the range browsers clamp — EMU leaked back in")
}
#expect(svg.contains("scale(12700)"), "text was not scaled back into EMU space")
}

/// The renderer resolves a run's typeface to choose wrapping metrics; if it
/// does not also say so in the markup, every deck renders in the viewer's
/// default serif no matter what its brand font is.
@Test func aResolvedTypefaceIsNamedInTheMarkup() throws {
let deck = try Presentation()
let slide = try deck.slides[0]
let shape = try slide.shapes.addTextBox(Rect(x: EMU(914_400), y: EMU(914_400),
width: EMU(5_486_400), height: EMU(1_828_800)))
let frame = try #require(shape.textFrame)
let run = frame.addParagraph().addRun("Branded")
run.fontName = "Georgia"

let svg = try deck.renderSVG(slideAt: 0)

#expect(svg.contains("font-family=\"Georgia"), "the resolved typeface never reached the markup")
}

/// Regex-free attribute scrape: the renderer's output is the contract, and
/// a test that parsed it with the library's own XML would hide a malformed
/// emission.
private func matches(of pattern: String, in text: String) -> [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 }
}
}
83 changes: 83 additions & 0 deletions Tests/RostrumTests/UntypedEntryTests.swift
Original file line number Diff line number Diff line change
@@ -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" })
}
}
Loading