//! Vá font subset trích từ PDF cho WebView dùng được qua FontFace. //! //! OTS (OpenType Sanitizer của Chromium) BẮT BUỘC font TrueType phải có bảng //! `OS/2`, nhưng subset nhúng trong PDF thường bị trình tạo file bỏ bảng này //! (PDF không cần) → `new FontFace(...)` ném "OTS parsing error: OS/2: //! missing required table". Đã bắt được đúng thông điệp đó qua CDP Log trên //! font NotoSansMono-Bold subset (12 bảng, đủ name/post/cmap, thiếu mỗi //! OS/2). //! //! Ở đây dựng OS/2 version 4 tối thiểu từ `head` (unitsPerEm) + `hhea` //! (ascender/descender/lineGap) + cờ đậm/nghiêng lấy từ chính text object, //! rồi ghép lại sfnt: danh mục bảng sắp theo tag, offset pad 4 byte, //! checksum từng bảng + head.checkSumAdjustment chuẩn. Sfnt tag `true` //! (TrueType kiểu Apple) được chuẩn hoá về 0x00010000. Dữ liệu không phải //! sfnt (CFF trần của font Type1C...) trả nguyên trạng — UI tự rơi về stack //! font hệ thống. use std::collections::HashMap; /// Bytes font sẵn sàng cho FontFace: vá nếu cần, còn lại giữ nguyên. /// `code_to_uni`: map mã→Unicode từ /ToUnicode của PDF (cho các mã tuỳ biến /// ngoài ASCII — chữ Việt trong subset LibreOffice); None → chỉ ASCII /// identity + MacRoman. pub(crate) fn web_compatible_font( data: &[u8], bold: bool, italic: bool, code_to_uni: Option<&HashMap>, ) -> Vec { try_fix(data, bold, italic, code_to_uni).unwrap_or_else(|| data.to_vec()) } const TAG_TRUE: u32 = u32::from_be_bytes(*b"true"); const TAG_OTTO: u32 = u32::from_be_bytes(*b"OTTO"); fn try_fix( data: &[u8], bold: bool, italic: bool, code_to_uni: Option<&HashMap>, ) -> Option> { if data.len() < 12 { return None; } let ver = u32::from_be_bytes(data[0..4].try_into().ok()?); if ver != 0x0001_0000 && ver != TAG_TRUE && ver != TAG_OTTO { return None; // không phải sfnt (CFF trần...) — không đụng vào } let num = u16::from_be_bytes(data[4..6].try_into().ok()?) as usize; if num == 0 || data.len() < 12 + num * 16 { return None; } let mut tables: Vec<([u8; 4], Vec)> = Vec::with_capacity(num + 1); let mut has_os2 = false; for i in 0..num { let o = 12 + i * 16; let tag: [u8; 4] = data[o..o + 4].try_into().ok()?; let off = u32::from_be_bytes(data[o + 8..o + 12].try_into().ok()?) as usize; let len = u32::from_be_bytes(data[o + 12..o + 16].try_into().ok()?) as usize; if off.checked_add(len)? > data.len() { return None; // danh mục hỏng — trả nguyên trạng cho lành } if &tag == b"OS/2" { has_os2 = true; } tables.push((tag, data[off..off + len].to_vec())); } // cmap (1,0) format 0/6 — OTS không hỗ trợ → chuyển sang (3,1) format 4. let mut cmap_changed = false; if let Some(ci) = tables.iter().position(|(t, _)| t == b"cmap") { if let Some(converted) = convert_cmap(&tables[ci].1, code_to_uni) { tables[ci].1 = converted; cmap_changed = true; } } if has_os2 && ver == 0x0001_0000 && !cmap_changed { return None; // đã chuẩn sẵn — giữ nguyên bytes gốc } if !has_os2 { let os2 = build_os2(&tables, bold, italic)?; tables.push((*b"OS/2", os2)); } tables.sort_by(|a, b| a.0.cmp(&b.0)); Some(assemble(ver, tables)) } /// MacRoman 0x80–0xFF → Unicode (bảng chuẩn Apple) — fallback cho mã cao khi /// không có /ToUnicode. const MACROMAN_HI: [u16; 128] = [ 0x00C4, 0x00C5, 0x00C7, 0x00C9, 0x00D1, 0x00D6, 0x00DC, 0x00E1, 0x00E0, 0x00E2, 0x00E4, 0x00E3, 0x00E5, 0x00E7, 0x00E9, 0x00E8, 0x00EA, 0x00EB, 0x00ED, 0x00EC, 0x00EE, 0x00EF, 0x00F1, 0x00F3, 0x00F2, 0x00F4, 0x00F6, 0x00F5, 0x00FA, 0x00F9, 0x00FB, 0x00FC, 0x2020, 0x00B0, 0x00A2, 0x00A3, 0x00A7, 0x2022, 0x00B6, 0x00DF, 0x00AE, 0x00A9, 0x2122, 0x00B4, 0x00A8, 0x2260, 0x00C6, 0x00D8, 0x221E, 0x00B1, 0x2264, 0x2265, 0x00A5, 0x00B5, 0x2202, 0x2211, 0x220F, 0x03C0, 0x222B, 0x00AA, 0x00BA, 0x03A9, 0x00E6, 0x00F8, 0x00BF, 0x00A1, 0x00AC, 0x221A, 0x0192, 0x2248, 0x2206, 0x00AB, 0x00BB, 0x2026, 0x00A0, 0x00C0, 0x00C3, 0x00D5, 0x0152, 0x0153, 0x2013, 0x2014, 0x201C, 0x201D, 0x2018, 0x2019, 0x00F7, 0x25CA, 0x00FF, 0x0178, 0x2044, 0x20AC, 0x2039, 0x203A, 0xFB01, 0xFB02, 0x2021, 0x00B7, 0x201A, 0x201E, 0x2030, 0x00C2, 0x00CA, 0x00C1, 0x00CB, 0x00C8, 0x00CD, 0x00CE, 0x00CF, 0x00CC, 0x00D3, 0x00D4, 0xF8FF, 0x00D2, 0x00DA, 0x00DB, 0x00D9, 0x0131, 0x02C6, 0x02DC, 0x00AF, 0x02D8, 0x02D9, 0x02DA, 0x00B8, 0x02DD, 0x02DB, 0x02C7, ]; /// cmap chỉ có subtable OTS không hỗ trợ ((1,0) format 0/6 của subset PDF) → /// dựng lại thành (3,1) format 4 theo Unicode. Đã có subtable Unicode/Windows /// → None (giữ nguyên). Mã→Unicode: ưu tiên /ToUnicode; thiếu thì ASCII /// identity (0x20–0x7E) + MacRoman (≥0x80); mã thấp không giải nghĩa được thì /// bỏ — ký tự đó rơi xuống font kế trong stack CSS. fn convert_cmap(cmap: &[u8], code_to_uni: Option<&HashMap>) -> Option> { if cmap.len() < 4 { return None; } let n = u16::from_be_bytes(cmap[2..4].try_into().ok()?) as usize; if cmap.len() < 4 + n * 8 { return None; } let mut src: Option<(usize, u16)> = None; for i in 0..n { let o = 4 + i * 8; let plat = u16::from_be_bytes(cmap[o..o + 2].try_into().ok()?); let enc = u16::from_be_bytes(cmap[o + 2..o + 4].try_into().ok()?); let off = u32::from_be_bytes(cmap[o + 4..o + 8].try_into().ok()?) as usize; if off + 2 > cmap.len() { return None; } let fmt = u16::from_be_bytes(cmap[off..off + 2].try_into().ok()?); if plat == 0 || (plat == 3 && (enc == 1 || enc == 10)) { return None; // đã có subtable Unicode — OTS chấp nhận } if plat == 1 && enc == 0 && (fmt == 0 || fmt == 6) { src = Some((off, fmt)); } } let (off, fmt) = src?; let mut pairs: Vec<(u32, u16)> = Vec::new(); let mut push = |code: u32, glyph: u16| { if glyph == 0 { return; } let uni = match code_to_uni.and_then(|m| m.get(&code)) { Some(&u) => u, None => { if (0x20..=0x7E).contains(&code) { code } else if (0x80..=0xFF).contains(&code) { u32::from(MACROMAN_HI[(code - 0x80) as usize]) } else { return; // mã thấp không giải nghĩa được } } }; if uni == 0 || uni > 0xFFFF { return; // format 4 chỉ chứa BMP } pairs.push((uni, glyph)); }; if fmt == 0 { if off + 6 + 256 > cmap.len() { return None; } for c in 0..256u32 { push(c, u16::from(cmap[off + 6 + c as usize])); } } else { if off + 10 > cmap.len() { return None; } let first = u32::from(u16::from_be_bytes(cmap[off + 6..off + 8].try_into().ok()?)); let cnt = u16::from_be_bytes(cmap[off + 8..off + 10].try_into().ok()?) as usize; if off + 10 + cnt * 2 > cmap.len() { return None; } for k in 0..cnt { let g = u16::from_be_bytes(cmap[off + 10 + k * 2..off + 12 + k * 2].try_into().ok()?); push(first + k as u32, g); } } if pairs.is_empty() { return None; } pairs.sort_by_key(|p| p.0); pairs.dedup_by_key(|p| p.0); Some(build_cmap_format4(&pairs)) } /// cmap trọn vẹn: 1 subtable (3,1) format 4 từ các cặp (unicode, glyph) đã /// sort + khử trùng. Cặp liên tiếp cả 2 chiều gộp thành 1 segment. fn build_cmap_format4(pairs: &[(u32, u16)]) -> Vec { struct Seg { start: u32, end: u32, g_start: u16, delta_fixed: Option, } let mut segs: Vec = Vec::new(); for &(u, g) in pairs { if let Some(last) = segs.last_mut() { let next_g = last.g_start as u32 + (u - last.start); if u == last.end + 1 && next_g <= 0xFFFF && next_g == u32::from(g) { last.end = u; continue; } } segs.push(Seg { start: u, end: u, g_start: g, delta_fixed: None }); } segs.push(Seg { start: 0xFFFF, end: 0xFFFF, g_start: 0, delta_fixed: Some(1) }); let n = segs.len(); let sub_len = 16 + n * 8; let mut pow = 1usize; let mut es = 0u16; while pow * 2 <= n { pow *= 2; es += 1; } let mut sub = vec![0u8; sub_len]; let w16 = |b: &mut [u8], o: usize, v: u16| b[o..o + 2].copy_from_slice(&v.to_be_bytes()); w16(&mut sub, 0, 4); w16(&mut sub, 2, sub_len as u16); w16(&mut sub, 4, 0); // language w16(&mut sub, 6, (n * 2) as u16); w16(&mut sub, 8, (pow * 2) as u16); w16(&mut sub, 10, es); w16(&mut sub, 12, (n * 2 - pow * 2) as u16); for (i, s) in segs.iter().enumerate() { w16(&mut sub, 14 + i * 2, s.end as u16); // endCode w16(&mut sub, 16 + n * 2 + i * 2, s.start as u16); // startCode (sau pad) let delta = s .delta_fixed .unwrap_or_else(|| (u32::from(s.g_start).wrapping_sub(s.start) & 0xFFFF) as u16); w16(&mut sub, 16 + n * 4 + i * 2, delta); // idDelta w16(&mut sub, 16 + n * 6 + i * 2, 0); // idRangeOffset } let mut cmap = vec![0u8; 12 + sub_len]; w16(&mut cmap, 0, 0); // version w16(&mut cmap, 2, 1); // numTables w16(&mut cmap, 4, 3); // platformID Windows w16(&mut cmap, 6, 1); // encodingID Unicode BMP cmap[8..12].copy_from_slice(&12u32.to_be_bytes()); cmap[12..].copy_from_slice(&sub); cmap } /// Tổng checksum kiểu sfnt: cộng u32 big-endian, phần dư pad 0. fn checksum(data: &[u8]) -> u32 { let mut sum = 0u32; let mut chunks = data.chunks_exact(4); for c in &mut chunks { sum = sum.wrapping_add(u32::from_be_bytes(c.try_into().unwrap())); } let rem = chunks.remainder(); if !rem.is_empty() { let mut last = [0u8; 4]; last[..rem.len()].copy_from_slice(rem); sum = sum.wrapping_add(u32::from_be_bytes(last)); } sum } fn assemble(orig_ver: u32, tables: Vec<([u8; 4], Vec)>) -> Vec { // 'true' → 0x00010000 (OTS chấp nhận cả hai nhưng chuẩn hoá cho chắc); // OTTO (CFF) giữ nguyên tag. let ver = if orig_ver == TAG_OTTO { orig_ver } else { 0x0001_0000 }; let num = tables.len() as u16; let mut pow = 1u16; let mut entry_selector = 0u16; while pow * 2 <= num { pow *= 2; entry_selector += 1; } let search_range = pow * 16; let range_shift = num * 16 - search_range; let mut out = Vec::new(); out.extend_from_slice(&ver.to_be_bytes()); out.extend_from_slice(&num.to_be_bytes()); out.extend_from_slice(&search_range.to_be_bytes()); out.extend_from_slice(&entry_selector.to_be_bytes()); out.extend_from_slice(&range_shift.to_be_bytes()); let mut off = 12 + tables.len() * 16; let mut dir: Vec = Vec::new(); let mut bodies: Vec = Vec::new(); let mut head_off = None; for (tag, mut body) in tables { if &tag == b"head" && body.len() >= 12 { // checkSumAdjustment phải = 0 khi tính checksum — điền lại ở cuối. body[8..12].fill(0); head_off = Some(off); } let cs = checksum(&body); dir.extend_from_slice(&tag); dir.extend_from_slice(&cs.to_be_bytes()); dir.extend_from_slice(&(off as u32).to_be_bytes()); dir.extend_from_slice(&(body.len() as u32).to_be_bytes()); let padded = (body.len() + 3) & !3; off += padded; body.resize(padded, 0); bodies.extend_from_slice(&body); } out.extend_from_slice(&dir); out.extend_from_slice(&bodies); if let Some(h) = head_off { let adj = 0xB1B0_AFBAu32.wrapping_sub(checksum(&out)); out[h + 8..h + 12].copy_from_slice(&adj.to_be_bytes()); } out } /// OS/2 version 4 (96 byte) với số liệu suy từ head/hhea — đủ cho OTS và cho /// trình duyệt chọn đúng mặt đậm/nghiêng, không ảnh hưởng shaping. fn build_os2(tables: &[([u8; 4], Vec)], bold: bool, italic: bool) -> Option> { let find = |tag: &[u8; 4]| tables.iter().find(|(t, _)| t == tag).map(|(_, b)| b.as_slice()); let head = find(b"head")?; let upm = if head.len() >= 20 { i32::from(u16::from_be_bytes(head[18..20].try_into().ok()?)) } else { 1000 }; let (asc, desc, gap) = match find(b"hhea") { Some(h) if h.len() >= 10 => ( i16::from_be_bytes(h[4..6].try_into().ok()?), i16::from_be_bytes(h[6..8].try_into().ok()?), i16::from_be_bytes(h[8..10].try_into().ok()?), ), _ => ((upm * 4 / 5) as i16, -((upm / 5) as i16), 0), }; let mut v: Vec = Vec::with_capacity(96); fn u(v: &mut Vec, x: u16) { v.extend_from_slice(&x.to_be_bytes()); } fn i(v: &mut Vec, x: i16) { v.extend_from_slice(&x.to_be_bytes()); } fn d(v: &mut Vec, x: u32) { v.extend_from_slice(&x.to_be_bytes()); } u(&mut v, 4); // version i(&mut v, (upm / 2) as i16); // xAvgCharWidth (xấp xỉ — không dùng để shaping) u(&mut v, if bold { 700 } else { 400 }); // usWeightClass u(&mut v, 5); // usWidthClass = medium u(&mut v, 0); // fsType: không hạn chế nhúng let sub = (upm * 65 / 100) as i16; i(&mut v, sub); // ySubscriptXSize i(&mut v, sub); // ySubscriptYSize i(&mut v, 0); // ySubscriptXOffset i(&mut v, (upm * 14 / 100) as i16); // ySubscriptYOffset i(&mut v, sub); // ySuperscriptXSize i(&mut v, sub); // ySuperscriptYSize i(&mut v, 0); // ySuperscriptXOffset i(&mut v, (upm * 48 / 100) as i16); // ySuperscriptYOffset i(&mut v, (upm * 5 / 100) as i16); // yStrikeoutSize i(&mut v, (upm * 26 / 100) as i16); // yStrikeoutPosition i(&mut v, 0); // sFamilyClass v.extend_from_slice(&[0u8; 10]); // panose: any d(&mut v, 0); d(&mut v, 0); d(&mut v, 0); d(&mut v, 0); // ulUnicodeRange 1–4 v.extend_from_slice(b"FFXT"); // achVendID let mut fs = 0u16; if italic { fs |= 0x0001; } if bold { fs |= 0x0020; } if fs == 0 { fs = 0x0040; } // REGULAR u(&mut v, fs); // fsSelection u(&mut v, 0x0020); // usFirstCharIndex u(&mut v, 0xFFFD); // usLastCharIndex i(&mut v, asc); // sTypoAscender i(&mut v, desc); // sTypoDescender i(&mut v, gap); // sTypoLineGap u(&mut v, asc.max(0) as u16); // usWinAscent u(&mut v, (-(desc.min(0))) as u16); // usWinDescent d(&mut v, 1); // ulCodePageRange1: Latin 1 d(&mut v, 0); // ulCodePageRange2 i(&mut v, (upm / 2) as i16); // sxHeight i(&mut v, (upm * 7 / 10) as i16); // sCapHeight u(&mut v, 0); // usDefaultChar u(&mut v, 0x0020); // usBreakChar u(&mut v, 0); // usMaxContext debug_assert_eq!(v.len(), 96); Some(v) } #[cfg(test)] mod tests { use super::*; /// Sfnt giả tối thiểu: head (54B, upm=1000) + hhea (36B) + glyf giả, /// KHÔNG có OS/2 — đúng hình dạng subset PDF gây lỗi OTS. fn fake_subset() -> Vec { let mut head = vec![0u8; 54]; head[18..20].copy_from_slice(&1000u16.to_be_bytes()); // unitsPerEm let mut hhea = vec![0u8; 36]; hhea[4..6].copy_from_slice(&800i16.to_be_bytes()); // ascender hhea[6..8].copy_from_slice(&(-200i16).to_be_bytes()); // descender hhea[8..10].copy_from_slice(&90i16.to_be_bytes()); // lineGap let glyf = vec![1u8, 2, 3, 4, 5]; // lẻ 4 byte để thử pad let tables: Vec<([u8; 4], Vec)> = vec![(*b"glyf", glyf), (*b"head", head), (*b"hhea", hhea)]; assemble(TAG_TRUE, tables) } fn table_dir(font: &[u8]) -> Vec<(String, usize, usize)> { let num = u16::from_be_bytes(font[4..6].try_into().unwrap()) as usize; (0..num) .map(|i| { let o = 12 + i * 16; ( String::from_utf8_lossy(&font[o..o + 4]).to_string(), u32::from_be_bytes(font[o + 8..o + 12].try_into().unwrap()) as usize, u32::from_be_bytes(font[o + 12..o + 16].try_into().unwrap()) as usize, ) }) .collect() } #[test] fn missing_os2_is_synthesized_with_valid_checksums() { let subset = fake_subset(); let fixed = web_compatible_font(&subset, true, false, None); assert_ne!(fixed, subset, "phải được vá"); // Tag chuẩn hoá + có OS/2 + danh mục sắp theo tag. assert_eq!(&fixed[0..4], &0x0001_0000u32.to_be_bytes()); let dir = table_dir(&fixed); let tags: Vec<&str> = dir.iter().map(|(t, _, _)| t.as_str()).collect(); assert_eq!(tags, vec!["OS/2", "glyf", "head", "hhea"], "sắp theo tag: {tags:?}"); // OS/2 đọc lại đúng nội dung suy ra. let (_, off, len) = dir[0].clone(); let os2 = &fixed[off..off + len]; assert_eq!(len, 96); assert_eq!(u16::from_be_bytes(os2[4..6].try_into().unwrap()), 700, "usWeightClass bold"); assert_eq!(u16::from_be_bytes(os2[62..64].try_into().unwrap()), 0x0020, "fsSelection BOLD"); assert_eq!( i16::from_be_bytes(os2[68..70].try_into().unwrap()), 800, "sTypoAscender từ hhea" ); // Checksum toàn font phải ra hằng số ma thuật khi head.adjustment đã điền. assert_eq!(checksum(&fixed), 0xB1B0_AFBA, "checkSumAdjustment chuẩn"); // Vá lần 2 phải là no-op (đã có OS/2 + tag chuẩn, không có cmap). assert_eq!(web_compatible_font(&fixed, true, false, None), fixed); } #[test] fn non_sfnt_data_passes_through() { let cff = vec![0x01, 0x00, 0x04, 0x04, 0xAA, 0xBB]; assert_eq!(web_compatible_font(&cff, false, false, None), cff); } /// cmap (1,0) format 0 kiểu subset LibreOffice: mã ASCII giữ nguyên, mã /// thấp (1) là chữ có dấu chỉ /ToUnicode giải nghĩa được. fn fake_subset_with_cmap() -> Vec { let mut sub = vec![0u8; 6 + 256]; sub[0..2].copy_from_slice(&0u16.to_be_bytes()); // format 0 sub[2..4].copy_from_slice(&262u16.to_be_bytes()); sub[6 + 0x41] = 5; // 'A' → glyph 5 sub[6 + 0x42] = 6; // 'B' → glyph 6 (liên tiếp → gộp segment) sub[6 + 1] = 7; // mã 1 → glyph 7 (chữ 'ả' qua ToUnicode) let mut cmap = vec![0u8; 12]; cmap[2..4].copy_from_slice(&1u16.to_be_bytes()); // numTables cmap[4..6].copy_from_slice(&1u16.to_be_bytes()); // platform Mac cmap[6..8].copy_from_slice(&0u16.to_be_bytes()); // encoding 0 cmap[8..12].copy_from_slice(&12u32.to_be_bytes()); cmap.extend_from_slice(&sub); let mut head = vec![0u8; 54]; head[18..20].copy_from_slice(&1000u16.to_be_bytes()); let tables: Vec<([u8; 4], Vec)> = vec![(*b"cmap", cmap), (*b"head", head)]; assemble(TAG_TRUE, tables) } /// Tra format 4: quét segment tuyến tính. fn lookup_f4(cmap: &[u8], uni: u16) -> u16 { assert_eq!(u16::from_be_bytes(cmap[4..6].try_into().unwrap()), 3, "platform Windows"); assert_eq!(u16::from_be_bytes(cmap[6..8].try_into().unwrap()), 1, "encoding BMP"); let off = u32::from_be_bytes(cmap[8..12].try_into().unwrap()) as usize; assert_eq!(u16::from_be_bytes(cmap[off..off + 2].try_into().unwrap()), 4, "format 4"); let n = (u16::from_be_bytes(cmap[off + 6..off + 8].try_into().unwrap()) / 2) as usize; for i in 0..n { let end = u16::from_be_bytes(cmap[off + 14 + i * 2..off + 16 + i * 2].try_into().unwrap()); let start = u16::from_be_bytes(cmap[off + 16 + n * 2 + i * 2..off + 18 + n * 2 + i * 2].try_into().unwrap()); if uni >= start && uni <= end { let delta = u16::from_be_bytes(cmap[off + 16 + n * 4 + i * 2..off + 18 + n * 4 + i * 2].try_into().unwrap()); return uni.wrapping_add(delta); } } 0 } #[test] fn unsupported_cmap_is_converted_to_windows_format4() { let subset = fake_subset_with_cmap(); let mut uni = HashMap::new(); uni.insert(1u32, 0x1EA3u32); // mã 1 = 'ả' let fixed = web_compatible_font(&subset, false, false, Some(&uni)); assert_ne!(fixed, subset); // Tìm bảng cmap mới. let num = u16::from_be_bytes(fixed[4..6].try_into().unwrap()) as usize; let mut cm = None; for i in 0..num { let o = 12 + i * 16; if &fixed[o..o + 4] == b"cmap" { let off = u32::from_be_bytes(fixed[o + 8..o + 12].try_into().unwrap()) as usize; let len = u32::from_be_bytes(fixed[o + 12..o + 16].try_into().unwrap()) as usize; cm = Some(fixed[off..off + len].to_vec()); } } let cm = cm.expect("có cmap"); assert_eq!(lookup_f4(&cm, 0x41), 5, "'A' giữ glyph cũ"); assert_eq!(lookup_f4(&cm, 0x42), 6, "'B' gộp segment vẫn đúng"); assert_eq!(lookup_f4(&cm, 0x1EA3), 7, "'ả' theo ToUnicode"); assert_eq!(lookup_f4(&cm, 0x43), 0, "mã không map → notdef"); // OS/2 cũng được thêm (fake subset không có). let tags: Vec = (0..u16::from_be_bytes(fixed[4..6].try_into().unwrap()) as usize) .map(|i| String::from_utf8_lossy(&fixed[12 + i * 16..16 + i * 16]).to_string()) .collect(); assert!(tags.contains(&"OS/2".to_string()), "tags: {tags:?}"); } }