///|
priv struct RunColors {
theme : Map[String, String]
styles : Map[String, (String?, Map[String, String])]
default_styles : Map[String, String]
resolved_styles : Map[String, Map[String, String?]]
defaults : Map[String, String]
}
///|
fn RunColors::empty() -> RunColors {
{
theme: Map([]),
styles: Map([]),
default_styles: Map([]),
resolved_styles: Map([]),
defaults: Map([]),
}
}
///|
fn color_hex(value : String) -> String? {
if value.length() == 6 &&
value
.iter()
.all(c => {
(c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')
}) {
Some(value.to_upper())
} else {
None
}
}
///|
fn color_byte(value : String) -> Int32? {
if value.length() != 2 {
return None
}
let mut result = 0
for c in value.to_upper() {
let digit = if c >= '0' && c <= '9' {
c.to_int() - 48
} else if c >= 'A' && c <= 'F' {
c.to_int() - 55
} else {
return None
}
result = result * 16 + digit
}
Some(result)
}
///|
// Word changes HSL luminance; tint wins over shade when both are present.
// https://learn.microsoft.com/en-us/openspecs/office_standards/ms-oi29500/8229a077-7fc8-4fba-96cc-c77b6a4fc768
fn transform_theme_color(
rgb : String,
tint : String?,
shade : String?,
) -> String {
let amount = prefer_color(tint, shade)
guard amount is Some(amount) && color_byte(amount) is Some(byte) else {
return rgb
}
if byte == 255 {
return rgb
}
let channels = [
color_byte(rgb[0:2].to_owned()).unwrap().to_double() / 255.0,
color_byte(rgb[2:4].to_owned()).unwrap().to_double() / 255.0,
color_byte(rgb[4:6].to_owned()).unwrap().to_double() / 255.0,
]
let high = channels[0].max(channels[1]).max(channels[2])
let low = channels[0].min(channels[1]).min(channels[2])
let light = (high + low) / 2.0
let factor = byte.to_double() / 255.0
let new_light = if tint is Some(_) {
1.0 - (1.0 - light) * factor
} else {
light * factor
}
let old_range = if light <= 0.5 { light * 2.0 } else { (1.0 - light) * 2.0 }
let new_range = if new_light <= 0.5 {
new_light * 2.0
} else {
(1.0 - new_light) * 2.0
}
let scale = if old_range == 0.0 { 0.0 } else { new_range / old_range }
let hex = "0123456789ABCDEF"
let out = StringBuilder()
for channel in channels {
let n = (((channel - light) * scale + new_light) * 255.0 + 0.000000001)
.to_int()
.max(0)
.min(255)
out.write_char(hex[n / 16].to_int().unsafe_to_char())
out.write_char(hex[n % 16].to_int().unsafe_to_char())
}
out.to_string()
}
///|
fn RunColors::resolve(
self : RunColors,
value : String?,
theme : String?,
tint : String?,
shade : String?,
) -> String? {
if theme is Some(name) && self.theme.get(name) is Some(rgb) {
return Some(transform_theme_color(rgb, tint, shade))
}
match value {
Some("auto") => Some("auto")
Some(value) => color_hex(value)
None => None
}
}
///|
fn RunColors::read_node_color(self : RunColors, node : ReaderNode) -> String? {
self.resolve(
node.attribute("w:val"),
node.attribute("w:themeColor"),
node.attribute("w:themeTint"),
node.attribute("w:themeShade"),
)
}
///|
fn RunColors::read_color(self : RunColors, node : XmlElement) -> String? {
self.resolve(
node.attributes.get("w:val"),
node.attributes.get("w:themeColor"),
node.attributes.get("w:themeTint"),
node.attributes.get("w:themeShade"),
)
}
///|
fn RunColors::style_color(
self : RunColors,
kind : String,
id : String?,
) -> String? {
self.style_value(kind, id, "color")
}
///|
// Resolve each property independently: an absent value continues through basedOn.
fn RunColors::style_value(
self : RunColors,
kind : String,
id : String?,
property : String,
) -> String? {
let cache = self.resolved_styles.get_or_default(property, Map([]))
self.resolved_styles[property] = cache
let mut current = prefer_color(id, self.default_styles.get(kind))
let seen : Map[String, Bool] = Map([])
let mut result : String? = None
while current is Some(id) {
let key = style_lookup_key(kind, id)
if cache.get(key) is Some(cached) {
result = cached
break
}
if seen.contains(key) {
break
}
seen[key] = true
match self.styles.get(key) {
Some((parent, values)) => {
let value = values.get(property)
if value is Some(_) {
result = value
break
}
current = parent
}
None => break
}
}
for key, _ in seen {
cache[key] = result
}
result
}
///|
fn read_run_colors(
zip : ZipArchive,
relationships : Relationships,
base_path : String,
styles_root : XmlElement?,
xml_budget? : @xml.XmlReadBudget,
) -> RunColors raise DocxError {
let theme_path = resolve_unique_internal_part(
zip,
relationships,
[
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme",
"http://purl.oclc.org/ooxml/officeDocument/relationships/theme",
],
base_path,
"theme",
reject_multiple_relationships=true,
)
let colors = RunColors::empty()
if theme_path is Some(path) {
let root = read_xml_part(zip, path, xml_budget?)
let scheme = root
.first_or_empty("a:themeElements")
.first_or_empty("a:clrScheme")
for
(name, tag) in [
("dark1", "dk1"),
("light1", "lt1"),
("dark2", "dk2"),
("light2", "lt2"),
("accent1", "accent1"),
("accent2", "accent2"),
("accent3", "accent3"),
("accent4", "accent4"),
("accent5", "accent5"),
("accent6", "accent6"),
("hyperlink", "hlink"),
("followedHyperlink", "folHlink"),
] {
let entry = scheme.first_or_empty("a:" + tag)
let value = prefer_color(
entry.first_or_empty("a:srgbClr").attributes.get("val"),
entry.first_or_empty("a:sysClr").attributes.get("lastClr"),
)
if value is Some(value) && color_hex(value) is Some(rgb) {
colors.theme[name] = rgb
}
}
}
for
(slot, name) in [
("text1", "dark1"),
("background1", "light1"),
("text2", "dark2"),
("background2", "light2"),
] {
if colors.theme.get(name) is Some(value) {
colors.theme[slot] = value
}
}
let settings_path = resolve_unique_internal_part(
zip,
relationships,
[
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings",
"http://purl.oclc.org/ooxml/officeDocument/relationships/settings",
],
base_path,
"settings",
reject_multiple_relationships=true,
)
if settings_path is Some(path) {
let mapping = read_xml_part(zip, path, xml_budget?).first_or_empty(
"w:clrSchemeMapping",
)
let palette = colors.theme.copy()
for
(slot, attribute) in [
("text1", "t1"),
("background1", "bg1"),
("text2", "t2"),
("background2", "bg2"),
("accent1", "accent1"),
("accent2", "accent2"),
("accent3", "accent3"),
("accent4", "accent4"),
("accent5", "accent5"),
("accent6", "accent6"),
("hyperlink", "hyperlink"),
("followedHyperlink", "followedHyperlink"),
] {
if mapping.attributes.get("w:" + attribute) is Some(name) &&
palette.get(name) is Some(value) {
colors.theme[slot] = value
}
}
}
guard styles_root is Some(root) else { return colors }
for style in root.elements_by_tag_name("w:style") {
let kind = style.attributes.get_or_default("w:type", "")
let id = style.attributes.get_or_default("w:styleId", "")
let key = style_lookup_key(kind, id)
if id != "" && !colors.styles.contains(key) {
colors.styles[key] = (
style.first_or_empty("w:basedOn").attributes.get("w:val"),
colors.read_style_values(style.first_or_empty("w:rPr")),
)
if style.attributes.get("w:default") is Some("1" | "true" | "on") &&
!colors.default_styles.contains(kind) {
colors.default_styles[kind] = id
}
}
}
{
..colors,
defaults: colors.read_style_values(
root
.first_or_empty("w:docDefaults")
.first_or_empty("w:rPrDefault")
.first_or_empty("w:rPr"),
),
}
}
///|
fn prefer_color(first : String?, fallback : String?) -> String? {
match first {
Some(_) => first
None => fallback
}
}
///|
fn RunColors::read_style_values(
self : RunColors,
rpr : XmlElement,
) -> Map[String, String] {
let values : Map[String, String] = Map([])
if self.read_color(rpr.first_or_empty("w:color")) is Some(color) {
values["color"] = color
}
if read_highlight(rpr.first_or_empty("w:highlight").attributes.get("w:val"))
is Some(highlight) {
values["highlight"] = highlight
}
for property in ["sz", "szCs"] {
let raw = rpr.first_or_empty("w:" + property).attributes.get("w:val")
if read_font_size(raw) is Some(_) && raw is Some(value) {
values[property] = value
}
}
values
}