///|
suberror ParseError String
///|
pub impl Show for ParseError with to_string(e) {
if e is ParseError(m) {
m
} else {
"Unknown parse error"
}
}
///|
pub impl Show for ParseError with output(e, logger) {
logger.write_string(e.to_string())
}
///|
/// Load an ICC file from disk and decode it into a key-value map containing profile metadata.
///
/// Parameters:
///
/// * `path` : The file system path to the ICC profile file to be loaded and
/// parsed.
///
/// Returns a `Map[String, String]` containing the parsed ICC profile metadata
/// as key-value pairs. Common keys include "description", "manufacturer",
/// "model", "colorSpace", "connectionSpace", "intent", "version", "whitepoint",
/// and others depending on the profile content.
///
/// Throws an error of type `ParseError` if the file cannot be read from disk or
/// if the ICC profile data is invalid or corrupted.
///
/// Example:
///
/// ```moonbit
/// let profile_data = @enanandesu/icc.parse_from_file("./test/sRGB_IEC61966-2-1_black_scaled.icc")
/// inspect(profile_data.get_or_default("colorSpace", ""), content="RGB")
/// inspect(profile_data.get_or_default("creator", ""), content="")
/// ```
///
pub fn parse_from_file(path : String) -> Map[String, String] raise ParseError {
try {
let content = @fs.read_file_to_bytes(path)
let buf = @buffer.new(size_hint=content.length())
buf.write_bytes(content)
parse_from_buffer(buf)
} catch {
e => raise ParseError("Failed to parse file \{path}: \{e}")
}
}
///|
/// Parses an ICC profile file from the specified file path (alias for
/// `parse_from_file`).
pub fn parse(path : String) -> Map[String, String] raise ParseError {
parse_from_file(path)
}
///|
/// Parses an ICC profile from a buffer containing ICC profile data.
///
/// Parameters:
///
/// * `content` : A buffer containing the raw ICC profile data to be parsed.
///
/// Returns a `Map[String, String]` containing the parsed ICC profile metadata
/// as key-value pairs. Common keys include "description", "manufacturer",
/// "model", "colorSpace", "connectionSpace", "intent", "version", "whitepoint",
/// and others depending on the profile content.
///
/// Throws an error of type `ParseError` if the ICC profile data is invalid,
/// corrupted, or does not meet the minimum size requirements.
///
/// Example:
///
/// ```moonbit
/// // Assuming you have ICC profile data as a buffer
/// let icc_data_buffer = @buffer.new() // Your ICC profile data as buffer
/// let profile_data = @enanandesu/icc.parse_from_buffer(icc_data_buffer)
/// inspect(profile_data.get_or_default("colorSpace", ""), content="RGB")
/// ```
///
pub fn parse_from_buffer(
content : @buffer.Buffer,
) -> Map[String, String] raise ParseError {
parse_content(content)
}
///|
/// Parses an ICC profile from raw bytes data.
///
/// Parameters:
///
/// * `data` : The raw ICC profile data as bytes to be parsed.
///
/// Returns a `Map[String, String]` containing the parsed ICC profile metadata
/// as key-value pairs. Common keys include "description", "manufacturer",
/// "model", "colorSpace", "connectionSpace", "intent", "version", "whitepoint",
/// and others depending on the profile content.
///
/// Throws an error of type `ParseError` if the ICC profile data is invalid,
/// corrupted, or does not meet the minimum size requirements.
///
/// Example:
///
/// ```moonbit
/// // Assuming you have ICC profile data as bytes
/// let icc_bytes = b"..." // Your ICC profile data as bytes
/// let profile_data = @enanandesu/icc.parse_from_bytes(icc_bytes)
/// inspect(profile_data.get_or_default("colorSpace", ""), content="RGB")
/// ```
///
pub fn parse_from_bytes(data : Bytes) -> Map[String, String] raise ParseError {
let buf = @buffer.new(size_hint=data.length())
buf.write_bytes(data)
parse_from_buffer(buf)
}
///|
/// Parse ICC bytes carried inside a `String` (each code unit is treated as
/// raw byte data; callers must ensure the string contains the original profile
/// bytes, e.g. by using `to_unchecked_string()`).
///
/// Parameters:
///
/// * `data` : A string whose UTF-16 code units contain the raw ICC bytes.
/// Typical use cases are round-tripping binary data through `Bytes` ->
/// `String::from_bytes_unchecked()` (or similar) after base64 decoding.
///
/// Returns a `Map[String, String]` containing the parsed ICC metadata.
///
/// Throws `ParseError` when the string cannot be treated as ICC bytes or the
/// profile violates format constraints.
pub fn parse_from_string(data : String) -> Map[String, String] raise ParseError {
try {
let bytes = data.to_bytes()
let buf = @buffer.new(size_hint=bytes.length())
buf.write_bytes(bytes)
parse_from_buffer(buf)
} catch {
e => raise ParseError("Failed to parse string content: \{e}")
}
}
///|
fn parse_content(
content : @buffer.Buffer,
) -> Map[String, String] raise ParseError {
let bytes = content.to_bytes()
let data = bytes.to_fixedarray()
let len = data.length()
guard len >= 132 else {
raise ParseError("ICC profile is too small (expected at least 132 bytes)")
}
let cmm = describe_vendor(read_signature(data, 4))
let platform = describe_vendor(read_signature(data, 40))
let manufacturer = describe_vendor(read_signature(data, 48))
let model = read_signature(data, 52)
let creator = describe_vendor(read_signature(data, 80))
let version_major = data[8].to_int()
let minor_and_bugfix = data[9].to_int()
let version_minor = (minor_and_bugfix >> 4) & 0x0F
let version_bugfix = minor_and_bugfix & 0x0F
let version = version_major.to_string() +
"." +
version_minor.to_string() +
"." +
version_bugfix.to_string()
let device_class = describe_device_class(read_signature(data, 12))
let color_space = read_signature(data, 16)
let connection_space = read_signature(data, 20)
let intent_value = read_u32_be(data, 64)
let intent = intent_to_string(intent_value)
let whitepoint = get_whitepoint(data, len)
guard validate_tag_table(data, len) else {
raise ParseError("ICC profile tag table is invalid")
}
let description = get_tag_string(data, len, "desc")
let device_manufacturer_description = get_tag_string(data, len, "dmnd")
let device_model_description = get_tag_string(data, len, "dmdd")
let mut viewing_description = get_tag_string(data, len, "vued")
if viewing_description == "" {
viewing_description = get_tag_string(data, len, "view")
}
if viewing_description != "" && !is_printable_ascii(viewing_description) {
viewing_description = ""
}
let copyright = get_tag_string(data, len, "cprt")
let res : Map[String, String] = {}
if cmm != "" {
res["cmm"] = cmm
}
if color_space != "" {
res["colorSpace"] = color_space
}
if connection_space != "" {
res["connectionSpace"] = connection_space
}
if copyright != "" {
res["copyright"] = copyright
}
if creator != "" {
res["creator"] = creator
}
if description != "" {
res["description"] = description
}
if device_class != "" {
res["deviceClass"] = device_class
}
if device_manufacturer_description != "" {
res["deviceManufacturerDescription"] = device_manufacturer_description
}
if device_model_description != "" {
res["deviceModelDescription"] = device_model_description
}
res["intent"] = intent
if manufacturer != "" {
res["manufacturer"] = manufacturer
}
if model != "" {
res["model"] = model
}
if platform != "" {
res["platform"] = platform
}
res["version"] = version
if viewing_description != "" && is_printable_ascii(viewing_description) {
res["viewingConditionsDescription"] = viewing_description
}
if whitepoint != "" {
res["whitepoint"] = whitepoint
}
res
}
///|
fn read_u32_be(data : FixedArray[Byte], 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 read_s15_fixed16_be(data : FixedArray[Byte], offset : Int) -> Int {
let mut high = data[offset].to_int()
if high >= 128 {
high = high - 256
}
(high << 24) |
(data[offset + 1].to_int() << 16) |
(data[offset + 2].to_int() << 8) |
data[offset + 3].to_int()
}
///|
fn read_signature(data : FixedArray[Byte], offset : Int) -> String {
if offset + 4 > data.length() {
return ""
}
let builder = StringBuilder::new(size_hint=4)
for i in 0..<4 {
builder.write_char(Int::unsafe_to_char(data[offset + i].to_int()))
}
trim_ascii(builder.to_string())
}
///|
fn describe_device_class(code : String) -> String {
match code {
"" => ""
"scnr" | "SCNR" => "Scanner"
"mntr" | "MNTR" => "Monitor"
"prtr" | "PRTR" => "Printer"
"link" | "LINK" => "Link"
"abst" | "ABST" => "Abstract"
"spac" | "SPAC" => "Space"
"nmcl" | "NMCL" => "Named color"
_ => code
}
}
///|
fn describe_vendor(code : String) -> String {
match code {
"" => ""
"appl" | "APPL" => "Apple"
"adbe" | "ADBE" => "Adobe"
"msft" | "MSFT" => "Microsoft"
"sunw" | "SUNW" => "Sun Microsystems"
"sgi" | "SGI" => "Silicon Graphics"
"tgnt" | "TGNT" => "Taligent"
_ => code
}
}
///|
fn is_printable_ascii(text : String) -> Bool {
text.iter().all(ch => ch >= ' ' && ch <= '~')
}
///|
fn format_s15_fixed16(value : Int) -> String {
let mut val = value
let mut sign = ""
if val < 0 {
sign = "-"
val = -val
}
let integer = val / 65536
let remainder = val % 65536
if remainder == 0 {
return sign + integer.to_string()
}
let scaled = remainder.to_int64() * 152587890625L
let mut frac_str = scaled.to_string()
while frac_str.length() < 16 {
frac_str = "0" + frac_str
}
let mut trim_end = frac_str.length()
while trim_end > 0 && frac_str[trim_end - 1] == '0'.to_int() {
trim_end = trim_end - 1
}
let int_str = integer.to_string()
if trim_end == 0 {
return sign + int_str
}
let builder = StringBuilder::new(size_hint=trim_end)
for i in 0.. String {
let builder = StringBuilder::new(
size_hint=x.length() + y.length() + z.length() + 6,
)
builder.write_char('[')
builder.write_string(x)
builder.write_string(", ")
builder.write_string(y)
builder.write_string(", ")
builder.write_string(z)
builder.write_char(']')
builder.to_string()
}
///|
fn get_tag_xyz_string(
data : FixedArray[Byte],
len : Int,
target : String,
) -> String {
let (tag_offset, tag_size) = find_tag_entry(data, len, target)
if tag_offset < 0 || tag_size < 20 {
return ""
}
if tag_offset + 20 > len {
return ""
}
let tag_type = read_signature(data, tag_offset)
if tag_type != "XYZ" {
return ""
}
let x = format_s15_fixed16(read_s15_fixed16_be(data, tag_offset + 8))
let y = format_s15_fixed16(read_s15_fixed16_be(data, tag_offset + 12))
let z = format_s15_fixed16(read_s15_fixed16_be(data, tag_offset + 16))
format_xyz_array(x, y, z)
}
///|
fn get_whitepoint(data : FixedArray[Byte], len : Int) -> String {
let tag_value = get_tag_xyz_string(data, len, "wtpt")
if tag_value != "" {
return tag_value
}
let x = format_s15_fixed16(read_s15_fixed16_be(data, 68))
let y = format_s15_fixed16(read_s15_fixed16_be(data, 72))
let z = format_s15_fixed16(read_s15_fixed16_be(data, 76))
format_xyz_array(x, y, z)
}
///|
fn trim_ascii(text : String) -> String {
let mut start = 0
let mut end = text.length()
while start < end && is_ascii_trim_char(text[start]) {
start = start + 1
}
while end > start && is_ascii_trim_char(text[end - 1]) {
end = end - 1
}
if end <= start {
return ""
}
let builder = StringBuilder::new(size_hint=end - start)
for i in start.. Bool {
ch == ' '.to_int() ||
ch == '\t'.to_int() ||
ch == '\r'.to_int() ||
ch == '\n'.to_int() ||
ch == 0
}
///|
fn validate_tag_table(data : FixedArray[Byte], len : Int) -> Bool {
let tag_table_start = 128
if len < tag_table_start + 4 {
return false
}
let tag_count = read_u32_be(data, tag_table_start)
if tag_count < 0 {
return false
}
let entries_start = tag_table_start + 4
if entries_start >= len {
return false
}
let remaining = len - entries_start
let required = tag_count.to_int64() * 12L
if required > remaining.to_int64() {
return false
}
true
}
///|
fn get_tag_string(
data : FixedArray[Byte],
len : Int,
target : String,
) -> String {
let (tag_offset, tag_size) = find_tag_entry(data, len, target)
if tag_offset < 0 || tag_size <= 0 {
return ""
}
parse_tag_text(data, tag_offset, tag_size)
}
///|
fn find_tag_entry(
data : FixedArray[Byte],
len : Int,
target : String,
) -> (Int, Int) {
let tag_table_start = 128
if len < tag_table_start + 4 {
return (-1, 0)
}
let tag_count = read_u32_be(data, tag_table_start)
if tag_count <= 0 {
return (-1, 0)
}
let entries_start = tag_table_start + 4
if entries_start >= len {
return (-1, 0)
}
let max_entries = (len - entries_start) / 12
if tag_count > max_entries {
return (-1, 0)
}
for i in 0.. len {
break
}
let signature = read_signature(data, entry_offset)
if signature == target {
let data_offset = read_u32_be(data, entry_offset + 4)
let data_size = read_u32_be(data, entry_offset + 8)
if data_offset < 0 || data_size <= 0 {
return (-1, 0)
}
if data_offset + data_size > len {
return (-1, 0)
}
return (data_offset, data_size)
}
}
(-1, 0)
}
///|
fn parse_tag_text(data : FixedArray[Byte], offset : Int, size : Int) -> String {
if size < 8 {
return ""
}
let tag_type = read_signature(data, offset)
match tag_type {
"desc" => parse_desc_type(data, offset, size)
"mluc" => parse_mluc_type(data, offset, size)
"text" => parse_text_type(data, offset, size)
_ => parse_text_type(data, offset, size)
}
}
///|
fn parse_desc_type(data : FixedArray[Byte], offset : Int, size : Int) -> String {
if size < 12 {
return ""
}
let ascii_length = read_u32_be(data, offset + 8)
if ascii_length <= 0 || ascii_length > size - 12 {
return ""
}
let mut actual_length = ascii_length
if actual_length > 0 && data[offset + 12 + actual_length - 1].to_int() == 0 {
actual_length = actual_length - 1
}
trim_ascii(ascii_slice_to_string(data, offset + 12, actual_length))
}
///|
fn parse_text_type(data : FixedArray[Byte], offset : Int, size : Int) -> String {
if size <= 8 {
return ""
}
trim_ascii(ascii_slice_to_string(data, offset + 8, size - 8))
}
///|
fn ascii_slice_to_string(
data : FixedArray[Byte],
start : Int,
length : Int,
) -> String {
if length <= 0 || start + length > data.length() {
return ""
}
let builder = StringBuilder::new(size_hint=length)
for i in 0.. String {
if size < 16 {
return ""
}
let record_count = read_u32_be(data, offset + 8)
let record_size = read_u32_be(data, offset + 12)
if record_count <= 0 || record_size < 12 {
return ""
}
if offset + 16 + record_count * record_size > offset + size {
return ""
}
let records_base = offset + 16
let mut first_non_empty = ""
for i in 0.. offset + size {
continue
}
let length = read_u32_be(data, record_offset + 4)
let text_offset = read_u32_be(data, record_offset + 8)
if length <= 0 {
continue
}
let absolute_start = offset + text_offset
if absolute_start < offset + 16 || absolute_start + length > offset + size {
continue
}
let text = trim_ascii(utf16be_slice_to_string(data, absolute_start, length))
if text == "" {
continue
}
let lang = ascii_slice_to_string(data, record_offset, 2)
if lang == "en" {
return text
}
if first_non_empty == "" {
first_non_empty = text
}
}
first_non_empty
}
///|
fn utf16be_slice_to_string(
data : FixedArray[Byte],
start : Int,
length : Int,
) -> String {
if length <= 1 || start + length > data.length() {
return ""
}
let mut i = 0
let builder = StringBuilder::new(size_hint=length / 2)
while i + 1 < length {
let high = data[start + i].to_int()
let low = data[start + i + 1].to_int()
let code_unit = (high << 8) | low
if code_unit == 0 {
i = i + 2
continue
}
if code_unit >= 0xD800 && code_unit <= 0xDBFF {
if i + 3 >= length {
break
}
let next_high = data[start + i + 2].to_int()
let next_low = data[start + i + 3].to_int()
let next_code = (next_high << 8) | next_low
if next_code < 0xDC00 || next_code > 0xDFFF {
i = i + 2
continue
}
let code_point = 0x10000 +
((code_unit - 0xD800) << 10) +
(next_code - 0xDC00)
builder.write_char(Int::unsafe_to_char(code_point))
i = i + 4
continue
}
if code_unit >= 0xDC00 && code_unit <= 0xDFFF {
i = i + 2
continue
}
builder.write_char(Int::unsafe_to_char(code_unit))
i = i + 2
}
builder.to_string()
}
///|
fn intent_to_string(value : Int) -> String {
match value {
0 => "Perceptual"
1 => "Relative"
2 => "Saturation"
3 => "Absolute"
_ => "Unknown(" + value.to_string() + ")"
}
}