// F4 of the content writers: hyperlinks and inline images. Hyperlinks
// become `w:hyperlink` (an external relationship for `href`, the `w:anchor`
// attribute for internal anchors — the reader folds r:id+anchor into the
// href fragment, so setting BOTH fails closed to keep round trips exact).
// Images become minimal DrawingML `wp:inline` pictures with a media part
// per image; dimensions come from the image bytes themselves (PNG, JPEG,
// GIF — anything else fails closed), rendered at 96 DPI.
///|
const EMU_PER_PIXEL : Int64 = 9525
///|
/// ST_PositiveCoordinate's upper bound (EMUs).
const MAX_EMU : Int64 = 27273042316900
///|
fn write_hyperlink(
children : Array[DocumentElement],
href : String?,
anchor : String?,
target_frame : String?,
ctx : WriteContext,
) -> XmlElement raise DocxError {
let attributes : Map[String, String] = Map([])
match (href, anchor) {
(Some(_), Some(_)) =>
raise Unsupported(
message="a hyperlink with both href and anchor cannot round-trip (the reader folds the anchor into the href fragment); put the fragment in the href",
)
(Some(href), None) => {
if href == "" {
raise Unsupported(
message="a hyperlink href must not be empty (the reader treats blank attributes as absent)",
)
}
check_attribute_value("a hyperlink href", href)
let rel_id = ctx.allocate_relationship(
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink",
href,
external=true,
)
attributes["r:id"] = rel_id
}
(None, Some(anchor)) => {
if anchor == "" {
raise Unsupported(
message="a hyperlink anchor must not be empty (the reader treats blank attributes as absent)",
)
}
check_attribute_value("a hyperlink anchor", anchor)
attributes["w:anchor"] = anchor
}
(None, None) =>
raise Unsupported(message="a hyperlink needs an href or an anchor")
}
match target_frame {
Some(frame) => {
if frame == "" {
raise Unsupported(
message="a hyperlink target frame must not be empty (the reader treats blank attributes as absent)",
)
}
check_attribute_value("a hyperlink target frame", frame)
attributes["w:tgtFrame"] = frame
}
None => ()
}
let nodes : Array[XmlNode] = []
for child in children {
nodes.push(XmlElement(write_inline(child, ctx)))
}
@xml.xml_element("w:hyperlink", attributes~, children=nodes)
}
///|
fn write_image(image : Image, ctx : WriteContext) -> XmlElement raise DocxError {
let extension = match image.content_type {
"image/png" => "png"
"image/jpeg" => "jpeg"
"image/gif" => "gif"
other =>
raise Unsupported(
message="the image writer supports image/png, image/jpeg, and image/gif (got '\{other}')",
)
}
let (width, height) = image_dimensions(image.content_type, image.data)
// Extents in Int64: a 32-bit Int overflows at ~225k px, and OOXML bounds
// coordinates at ST_PositiveCoordinate's max.
let cx_emu = width.to_int64() * EMU_PER_PIXEL
let cy_emu = height.to_int64() * EMU_PER_PIXEL
if cx_emu <= 0 || cy_emu <= 0 || cx_emu > MAX_EMU || cy_emu > MAX_EMU {
raise Unsupported(
message="image dimensions \{width}x\{height} px are outside OOXML coordinate bounds",
)
}
let cx = cx_emu.to_string()
let cy = cy_emu.to_string()
let index = ctx.media.length() + 1
let part_name = "media/image\{index}.\{extension}"
ctx.media.push((part_name, extension, image.content_type, image.data))
let rel_id = ctx.allocate_relationship(
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image",
part_name,
external=false,
)
let name = "Picture \{index}"
let doc_pr_attributes : Map[String, String] = {
"id": index.to_string(),
"name": name,
}
let c_nv_pr_attributes : Map[String, String] = { "id": "0", "name": name }
match image.alt_text {
Some(alt) => {
if alt.trim(chars=" \t\r\n") == "" {
raise Unsupported(
message="image alt text must not be blank (the reader treats blank descr as absent)",
)
}
check_attribute_value("image alt text", alt)
doc_pr_attributes["descr"] = alt
c_nv_pr_attributes["descr"] = alt
}
None => ()
}
let extent : Map[String, String] = { "cx": cx, "cy": cy }
@xml.xml_element("w:drawing", children=[
XmlElement(
@xml.xml_element(
"wp:inline",
attributes={ "distT": "0", "distB": "0", "distL": "0", "distR": "0" },
children=[
XmlElement(@xml.xml_element("wp:extent", attributes=extent)),
XmlElement(@xml.xml_element("wp:docPr", attributes=doc_pr_attributes)),
XmlElement(
@xml.xml_element("a:graphic", children=[
XmlElement(
@xml.xml_element(
"a:graphicData",
attributes={
"uri": "http://schemas.openxmlformats.org/drawingml/2006/picture",
},
children=[
XmlElement(
@xml.xml_element("pic:pic", children=[
XmlElement(
@xml.xml_element("pic:nvPicPr", children=[
XmlElement(
@xml.xml_element(
"pic:cNvPr",
attributes=c_nv_pr_attributes,
),
),
XmlElement(@xml.xml_element("pic:cNvPicPr")),
]),
),
XmlElement(
@xml.xml_element("pic:blipFill", children=[
XmlElement(
@xml.xml_element("a:blip", attributes={
"r:embed": rel_id,
}),
),
XmlElement(
@xml.xml_element("a:stretch", children=[
XmlElement(@xml.xml_element("a:fillRect")),
]),
),
]),
),
XmlElement(
@xml.xml_element("pic:spPr", children=[
XmlElement(
@xml.xml_element("a:xfrm", children=[
XmlElement(
@xml.xml_element("a:off", attributes={
"x": "0",
"y": "0",
}),
),
XmlElement(
@xml.xml_element("a:ext", attributes=extent),
),
]),
),
XmlElement(
@xml.xml_element(
"a:prstGeom",
attributes={ "prst": "rect" },
children=[
XmlElement(@xml.xml_element("a:avLst")),
],
),
),
]),
),
]),
),
],
),
),
]),
),
],
),
),
])
}
///|
/// Pixel dimensions from the image header. Fail-closed: a file whose
/// header cannot be parsed (truncated, wrong magic) raises rather than
/// writing a picture with invented geometry.
fn image_dimensions(
content_type : String,
data : Bytes,
) -> (Int, Int) raise DocxError {
let dims = match content_type {
"image/png" => png_dimensions(data)
"image/jpeg" => jpeg_dimensions(data)
"image/gif" => gif_dimensions(data)
_ => None
}
match dims {
Some((width, height)) if width > 0 && height > 0 => (width, height)
_ =>
raise Unsupported(
message="could not read the image's dimensions from its \{content_type} header",
)
}
}
///|
fn be_u32(data : Bytes, offset : Int) -> Int {
(data[offset].to_int() << 24) |
(data[offset + 1].to_int() << 16) |
(data[offset + 2].to_int() << 8) |
data[offset + 3].to_int()
}
///|
fn png_dimensions(data : Bytes) -> (Int, Int)? {
// Full 8-byte signature, then the IHDR chunk: length(4)=13, 'IHDR'(4),
// width(4), height(4).
if data.length() < 24 {
return None
}
if data[0] != 0x89 ||
data[1] != b'P' ||
data[2] != b'N' ||
data[3] != b'G' ||
data[4] != 0x0D ||
data[5] != 0x0A ||
data[6] != 0x1A ||
data[7] != 0x0A {
return None
}
if be_u32(data, 8) != 13 {
return None
}
if data[12] != b'I' ||
data[13] != b'H' ||
data[14] != b'D' ||
data[15] != b'R' {
return None
}
Some((be_u32(data, 16), be_u32(data, 20)))
}
///|
fn gif_dimensions(data : Bytes) -> (Int, Int)? {
// Exactly 'GIF87a' or 'GIF89a', then the complete 7-byte logical screen
// descriptor (little-endian u16 width, height lead it).
if data.length() < 13 {
return None
}
if data[0] != b'G' ||
data[1] != b'I' ||
data[2] != b'F' ||
data[3] != b'8' ||
!(data[4] is (b'7' | b'9')) ||
data[5] != b'a' {
return None
}
let width = data[6].to_int() | (data[7].to_int() << 8)
let height = data[8].to_int() | (data[9].to_int() << 8)
Some((width, height))
}
///|
fn jpeg_dimensions(data : Bytes) -> (Int, Int)? {
// Scan JPEG segments for a start-of-frame marker (SOF0..SOF15 except
// DHT/JPG/DAC). Reads are bounded by each segment's DECLARED length —
// dimension bytes must lie inside the SOF segment and inside the data —
// legal 0xFF fill bytes are skipped, TEM (0x01) and RSTn/SOI are
// standalone, and EOI terminates the scan.
if data.length() < 4 || data[0] != 0xFF || data[1] != 0xD8 {
return None
}
let mut offset = 2
while offset + 1 < data.length() {
if data[offset] != 0xFF {
return None
}
// Skip marker fill bytes (any number of 0xFF before the marker code).
let mut marker_at = offset + 1
while marker_at < data.length() && data[marker_at] == 0xFF {
marker_at += 1
}
if marker_at >= data.length() {
return None
}
let marker = data[marker_at].to_int()
if marker == 0xD9 {
// EOI: no frame header seen.
return None
}
if marker == 0x01 || (marker >= 0xD0 && marker <= 0xD8) {
// TEM / RSTn / SOI: standalone, no length field.
offset = marker_at + 1
continue
}
if marker_at + 2 >= data.length() {
return None
}
let length = (data[marker_at + 1].to_int() << 8) |
data[marker_at + 2].to_int()
if length < 2 {
return None
}
let segment_end = marker_at + 1 + length
if segment_end > data.length() {
return None
}
let is_sof = marker >= 0xC0 &&
marker <= 0xCF &&
marker != 0xC4 &&
marker != 0xC8 &&
marker != 0xCC
if is_sof {
// Payload: precision(1) height(2) width(2) — must fit the declared
// segment (length covers the 2 length bytes + payload).
if length < 7 || marker_at + 7 >= data.length() {
return None
}
let height = (data[marker_at + 4].to_int() << 8) |
data[marker_at + 5].to_int()
let width = (data[marker_at + 6].to_int() << 8) |
data[marker_at + 7].to_int()
return Some((width, height))
}
offset = segment_end
}
None
}