1use std::borrow::Cow;
11use std::collections::BTreeSet;
12use std::path::Path;
13
14use object::FileKind;
15
16use crate::parsers::windows_executable::extract_windows_executable_metadata_text;
17use crate::utils::font::extract_font_metadata_text;
18use crate::utils::language::detect_language;
19
20use super::encoding::{
21 decode_bytes_to_string_with_diagnostic, looks_like_decoded_text, looks_like_textual_bytes,
22};
23use super::format_sniff::{
24 detect_file_format, is_supported_image_container, is_textual_format, is_zip_archive,
25 looks_like_bzip2, looks_like_deb, looks_like_gzip, looks_like_pdf, looks_like_rpm,
26 looks_like_rtf, looks_like_squashfs, looks_like_xz, media_mime_from_content,
27 supported_image_metadata_format,
28};
29use super::image_metadata::extract_image_metadata_text;
30use super::path::{PLAIN_TEXT_EXTENSIONS, lower_extension};
31use super::pdf::extract_pdf_text;
32
33pub(super) const LARGE_OPAQUE_BINARY_SKIP_BYTES: usize = 512 * 1024;
34const LARGE_MACHO_LEGAL_WINDOW_BYTES: usize = 64 * 1024;
35const LARGE_MACHO_LEGAL_MAX_WINDOWS: usize = 24;
36const LARGE_MACHO_LEGAL_MAX_WINDOWS_PER_MARKER: usize = 4;
37const LARGE_MACHO_LEGAL_MAX_EXTRACT_BYTES: usize = 2 * 1024 * 1024;
38const LARGE_MACHO_LEGAL_MARKERS: &[&[u8]] = &[
39 b"Unicode, Inc.",
40 b"http://www.unicode.org/copyright.html",
41 b"https://www.unicode.org/copyright.html",
42 b"SPDX-License-Identifier:",
43 b"Licensed under",
44 b"licensed under",
45 b"Apache License",
46 b"http://www.apache.org/licenses/",
47 b"https://www.apache.org/licenses/",
48 b"Permission is hereby granted",
49 b"permission is hereby granted",
50 b"Redistribution and use in source and binary forms",
51 b"redistribution and use in source and binary forms",
52 b"Permission to use, copy, modify, and/or distribute this software",
53 b"The MIT License",
54 b"GNU GENERAL PUBLIC LICENSE",
55 b"GNU LESSER GENERAL PUBLIC LICENSE",
56 b"Mozilla Public License",
57];
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum ExtractedTextKind {
61 None,
62 Decoded,
63 FontMetadata,
64 Pdf,
65 BinaryStrings,
66 ImageMetadata,
67 WindowsExecutableMetadata,
68}
69
70pub fn extract_text_for_detection(path: &Path, bytes: &[u8]) -> (String, ExtractedTextKind) {
71 let (text, kind, _) = extract_text_for_detection_with_diagnostics(path, bytes);
72 (text, kind)
73}
74
75pub(crate) fn augment_license_detection_text<'a>(path: &Path, text: &'a str) -> Cow<'a, str> {
76 let Some(extension) = path.extension().and_then(|ext| ext.to_str()) else {
77 return Cow::Borrowed(text);
78 };
79 if !matches!(
80 extension.to_ascii_lowercase().as_str(),
81 "md" | "markdown" | "html" | "htm"
82 ) {
83 return Cow::Borrowed(text);
84 }
85
86 let mut hints = Vec::new();
87 let has_dual_license_notice = has_dual_license_notice_text(text);
88 if text.contains("CC BY 4.0") || text.contains("creativecommons.org/licenses/by/4.0") {
89 hints.push("Creative Commons Attribution 4.0 International License".to_string());
90 }
91 if !has_dual_license_notice
92 && (text.contains("Apache License (Version 2.0)")
93 || text.contains("Apache License, Version 2.0"))
94 {
95 hints.push(
96 "Licensed under the Apache License, Version 2.0. http://www.apache.org/licenses/LICENSE-2.0"
97 .to_string(),
98 );
99 }
100
101 if !has_dual_license_notice {
102 hints.extend(extract_shields_license_badge_hints(text));
103 }
104
105 if hints.is_empty() {
106 Cow::Borrowed(text)
107 } else {
108 let mut augmented =
109 String::with_capacity(text.len() + hints.iter().map(String::len).sum::<usize>() + 8);
110 augmented.push_str(text);
111 augmented.push_str("\n\n");
112 for (index, hint) in hints.into_iter().enumerate() {
113 if index > 0 {
114 augmented.push('\n');
115 }
116 augmented.push_str(&hint);
117 }
118 Cow::Owned(augmented)
119 }
120}
121
122fn extract_shields_license_badge_hints(text: &str) -> Vec<String> {
123 let mut hints = Vec::new();
124 let mut rest = text;
125 let needle = "img.shields.io/badge/license-";
126
127 while let Some(index) = rest.find(needle) {
128 let start = index + needle.len();
129 let suffix = &rest[start..];
130 let end = suffix
131 .find([')', ']', '"', '\'', ' ', '\n'])
132 .unwrap_or(suffix.len());
133 let badge = &suffix[..end];
134 let Some(badge) = badge.strip_suffix(".svg") else {
135 rest = &suffix[end..];
136 continue;
137 };
138
139 let mut segments: Vec<_> = badge
140 .split('-')
141 .filter(|segment| !segment.is_empty())
142 .collect();
143 if segments.len() < 2 {
144 rest = &suffix[end..];
145 continue;
146 }
147 segments.pop();
148 let candidate = segments.join("-").replace("%20", " ").replace('_', "-");
149 if !candidate.is_empty() {
150 hints.push(canonical_shields_license_hint(&candidate));
151 }
152
153 rest = &suffix[end..];
154 }
155
156 hints.sort();
157 hints.dedup();
158 hints
159}
160
161fn has_dual_license_notice_text(text: &str) -> bool {
162 let lower = text.to_ascii_lowercase();
163 (lower.contains("licensed under either of") && lower.contains("at your option"))
164 || lower.contains("dual-licensed under")
165 || lower.contains("dual licensed under")
166}
167
168fn canonical_shields_license_hint(candidate: &str) -> String {
169 match candidate.trim() {
170 "MIT" => "The MIT License".to_string(),
171 "Apache-2.0" | "Apache 2.0" => "Apache License 2.0".to_string(),
172 other => format!("{other} License"),
173 }
174}
175
176pub(crate) fn extract_text_for_detection_with_diagnostics(
177 path: &Path,
178 bytes: &[u8],
179) -> (String, ExtractedTextKind, Option<String>) {
180 let ext = path
181 .extension()
182 .and_then(|e| e.to_str())
183 .map(|s| s.to_ascii_lowercase());
184 let detected_format = detect_file_format(bytes);
185
186 if looks_like_rtf(bytes, ext.as_deref()) {
187 let text = extract_rtf_text(bytes);
188 return if text.trim().is_empty() {
189 (String::new(), ExtractedTextKind::None, None)
190 } else {
191 (text, ExtractedTextKind::Decoded, None)
192 };
193 }
194
195 if looks_like_pdf(bytes) || detected_format.short_name() == Some("PDF") {
196 let (text, scan_error) = extract_pdf_text(path, bytes);
197 return if text.is_empty() {
198 (String::new(), ExtractedTextKind::None, scan_error)
199 } else {
200 (text, ExtractedTextKind::Pdf, None)
201 };
202 }
203
204 if let Some(format) = supported_image_metadata_format(ext.as_deref(), detected_format) {
205 let text = extract_image_metadata_text(bytes, format);
206 return if text.is_empty() {
207 if is_supported_image_container(bytes, format) {
208 (String::new(), ExtractedTextKind::None, None)
209 } else {
210 let (decoded, decode_diagnostic) = decode_bytes_to_string_with_diagnostic(bytes);
211 if decoded.is_empty() {
212 (String::new(), ExtractedTextKind::None, decode_diagnostic)
213 } else {
214 (decoded, ExtractedTextKind::Decoded, None)
215 }
216 }
217 } else {
218 (text, ExtractedTextKind::ImageMetadata, None)
219 };
220 }
221
222 if let Some(text) = extract_font_metadata_text(path, bytes) {
223 let strings = extract_printable_strings(bytes);
224 let combined = if strings.is_empty() {
225 text
226 } else {
227 combine_extracted_text_fragments(Some(text), strings)
228 };
229 return (combined, ExtractedTextKind::FontMetadata, None);
230 }
231
232 let windows_executable_metadata_text = extract_windows_executable_metadata_text(bytes);
233 let large_opaque_binary = windows_executable_metadata_text.is_none()
234 && is_large_opaque_binary_candidate(bytes, detected_format);
235 let bounded_macho_legal_text = if large_opaque_binary {
236 extract_bounded_macho_legal_strings(bytes)
237 } else {
238 String::new()
239 };
240 let skip_large_opaque_binary_text =
241 should_skip_large_opaque_binary_text_extraction(path, bytes, detected_format);
242
243 if skip_large_opaque_binary_text {
244 if !bounded_macho_legal_text.is_empty() {
245 return (
246 combine_extracted_text_fragments(
247 windows_executable_metadata_text,
248 bounded_macho_legal_text,
249 ),
250 ExtractedTextKind::BinaryStrings,
251 None,
252 );
253 }
254 return windows_metadata_or_empty_result(windows_executable_metadata_text);
255 }
256
257 if should_skip_binary_string_extraction(path, bytes, detected_format) {
258 return (String::new(), ExtractedTextKind::None, None);
259 }
260
261 let is_svg_text = lower_extension(path).as_deref() == Some("svg")
262 || detected_format.media_type() == "image/svg+xml";
263 let should_try_decoded_text = looks_like_textual_bytes(bytes) || is_svg_text;
264 let decoded_is_utf8 = std::str::from_utf8(bytes).is_ok();
265 let path_suggests_text = ext.as_deref().is_some_and(|extension| {
266 PLAIN_TEXT_EXTENSIONS.contains(&extension) || detect_language(path, bytes).is_some()
267 });
268
269 let mut decode_diagnostic = None;
270 if !large_opaque_binary && should_try_decoded_text {
271 let (decoded, diagnostic) = decode_bytes_to_string_with_diagnostic(bytes);
272 decode_diagnostic = diagnostic;
273 if !decoded.is_empty()
274 && (is_svg_text
275 || decoded_is_utf8
276 || path_suggests_text
277 || looks_like_decoded_text(&decoded))
278 {
279 let combined =
280 combine_extracted_text_fragments(windows_executable_metadata_text, decoded);
281 return (combined, ExtractedTextKind::Decoded, None);
282 }
283 }
284
285 let text = if large_opaque_binary {
286 let sampled_text = extract_sampled_printable_strings(bytes);
287 if bounded_macho_legal_text.is_empty() {
288 sampled_text
289 } else {
290 combine_extracted_text_fragments(Some(sampled_text), bounded_macho_legal_text)
291 }
292 } else {
293 extract_printable_strings(bytes)
294 };
295 if text.is_empty() {
296 let (result_text, result_kind, result_diagnostic) =
297 windows_metadata_or_empty_result(windows_executable_metadata_text);
298 let result_diagnostic = if result_text.is_empty() {
301 result_diagnostic.or(decode_diagnostic)
302 } else {
303 result_diagnostic
304 };
305 (result_text, result_kind, result_diagnostic)
306 } else {
307 (
308 combine_extracted_text_fragments(windows_executable_metadata_text, text),
309 ExtractedTextKind::BinaryStrings,
310 None,
311 )
312 }
313}
314
315fn combine_extracted_text_fragments(prefix: Option<String>, suffix: String) -> String {
316 match prefix {
317 Some(prefix) if !prefix.is_empty() && !suffix.is_empty() => format!("{prefix}\n{suffix}"),
318 Some(prefix) if !prefix.is_empty() => prefix,
319 _ => suffix,
320 }
321}
322
323pub(super) fn windows_metadata_or_empty_result(
324 windows_executable_metadata_text: Option<String>,
325) -> (String, ExtractedTextKind, Option<String>) {
326 if let Some(metadata_text) = windows_executable_metadata_text {
327 (
328 metadata_text,
329 ExtractedTextKind::WindowsExecutableMetadata,
330 None,
331 )
332 } else {
333 (String::new(), ExtractedTextKind::None, None)
334 }
335}
336
337fn extract_rtf_text(bytes: &[u8]) -> String {
338 let text = String::from_utf8_lossy(bytes);
339 let chars: Vec<char> = text.chars().collect();
340 let mut output = String::new();
341 let mut index = 0usize;
342
343 while index < chars.len() {
344 match chars[index] {
345 '{' | '}' => {
346 index += 1;
347 }
348 '\\' => {
349 index += 1;
350 if index >= chars.len() {
351 break;
352 }
353
354 match chars[index] {
355 '\\' | '{' | '}' => {
356 output.push(chars[index]);
357 index += 1;
358 }
359 '\'' => {
360 if index + 2 < chars.len() {
361 let hex = [chars[index + 1], chars[index + 2]];
362 let hex: String = hex.iter().collect();
363 if let Ok(value) = u8::from_str_radix(&hex, 16) {
364 output.push(value as char);
365 index += 3;
366 continue;
367 }
368 }
369 index += 1;
370 }
371 control if control.is_ascii_alphabetic() => {
372 let start = index;
373 while index < chars.len() && chars[index].is_ascii_alphabetic() {
374 index += 1;
375 }
376 let control_word: String = chars[start..index].iter().collect();
377
378 let number_start = index;
379 if index < chars.len()
380 && (chars[index] == '-' || chars[index].is_ascii_digit())
381 {
382 index += 1;
383 while index < chars.len() && chars[index].is_ascii_digit() {
384 index += 1;
385 }
386 }
387 let parameter: String = chars[number_start..index].iter().collect();
388
389 if index < chars.len() && chars[index] == ' ' {
390 index += 1;
391 }
392
393 match control_word.as_str() {
394 "par" | "line" => output.push('\n'),
395 "tab" => output.push('\t'),
396 "emdash" => output.push('—'),
397 "endash" => output.push('–'),
398 "bullet" => output.push('•'),
399 "lquote" | "rquote" => output.push('\''),
400 "ldblquote" | "rdblquote" => output.push('"'),
401 "u" => {
402 if let Ok(codepoint) = parameter.parse::<i32>() {
403 let normalized = if codepoint < 0 {
404 codepoint + 65_536
405 } else {
406 codepoint
407 };
408 if let Ok(normalized) = u32::try_from(normalized)
409 && let Some(ch) = char::from_u32(normalized)
410 {
411 output.push(ch);
412 }
413 }
414
415 if index < chars.len()
416 && !matches!(chars[index], '\\' | '{' | '}' | '\n' | '\r')
417 {
418 index += 1;
419 }
420 }
421 _ => {}
422 }
423 }
424 _ => {
425 index += 1;
426 }
427 }
428 }
429 ch => {
430 output.push(ch);
431 index += 1;
432 }
433 }
434 }
435
436 output
437 .replace(['\r', '\u{0c}'], "\n")
438 .lines()
439 .map(str::trim_end)
440 .collect::<Vec<_>>()
441 .join("\n")
442}
443
444fn should_skip_binary_string_extraction(
445 path: &Path,
446 bytes: &[u8],
447 detected_format: file_format::FileFormat,
448) -> bool {
449 use file_format::Kind as FileFormatKind;
450 matches!(lower_extension(path).as_deref(), Some("pdf"))
451 || supported_image_metadata_format(lower_extension(path).as_deref(), detected_format)
452 .is_some()
453 || (matches!(
454 detected_format.kind(),
455 FileFormatKind::Audio | FileFormatKind::Image | FileFormatKind::Video
456 ) && !is_textual_format(detected_format))
457 || media_mime_from_content(bytes).is_some()
458 || is_zip_archive(bytes)
459 || looks_like_gzip(bytes)
460 || looks_like_bzip2(bytes)
461 || looks_like_xz(bytes)
462 || looks_like_deb(bytes, path)
463 || looks_like_rpm(bytes, path)
464 || looks_like_squashfs(bytes, path)
465}
466
467fn should_skip_large_opaque_binary_text_extraction(
468 _path: &Path,
469 bytes: &[u8],
470 detected_format: file_format::FileFormat,
471) -> bool {
472 is_large_opaque_binary_candidate(bytes, detected_format)
473 && !sample_has_promising_printable_strings(bytes)
474}
475
476fn is_large_opaque_binary_candidate(
477 bytes: &[u8],
478 detected_format: file_format::FileFormat,
479) -> bool {
480 use file_format::Kind as FileFormatKind;
481 bytes.len() >= LARGE_OPAQUE_BINARY_SKIP_BYTES
482 && !is_textual_format(detected_format)
483 && !matches!(
484 detected_format.kind(),
485 FileFormatKind::Archive
486 | FileFormatKind::Compressed
487 | FileFormatKind::Package
488 | FileFormatKind::Audio
489 | FileFormatKind::Image
490 | FileFormatKind::Video
491 )
492}
493
494fn sampled_printable_window_ranges(len: usize) -> Vec<(usize, usize)> {
495 const SAMPLE_WINDOW_BYTES: usize = 64 * 1024;
496
497 let mut ranges = Vec::new();
498 let mut push_range = |start: usize, end: usize| {
499 if start < end && !ranges.contains(&(start, end)) {
500 ranges.push((start, end));
501 }
502 };
503
504 push_range(0, len.min(SAMPLE_WINDOW_BYTES));
505 if len > SAMPLE_WINDOW_BYTES * 2 {
506 let mid_start = len / 2 - SAMPLE_WINDOW_BYTES / 2;
507 let mid_end = (mid_start + SAMPLE_WINDOW_BYTES).min(len);
508 push_range(mid_start, mid_end);
509 }
510 if len > SAMPLE_WINDOW_BYTES {
511 push_range(len - SAMPLE_WINDOW_BYTES, len);
512 }
513
514 ranges
515}
516
517fn extract_bounded_macho_legal_strings(bytes: &[u8]) -> String {
518 if !matches!(
519 FileKind::parse(bytes),
520 Ok(FileKind::MachO32 | FileKind::MachO64 | FileKind::MachOFat32 | FileKind::MachOFat64)
521 ) {
522 return String::new();
523 }
524
525 let mut ranges = Vec::new();
526 for marker in LARGE_MACHO_LEGAL_MARKERS {
527 collect_marker_window_ranges(bytes, marker, &mut ranges);
528 if ranges.len() >= LARGE_MACHO_LEGAL_MAX_WINDOWS {
529 break;
530 }
531 }
532
533 if ranges.is_empty() {
534 return String::new();
535 }
536
537 let mut merged_ranges = merge_overlapping_ranges(ranges);
538 let mut combined_lines = BTreeSet::new();
539 let mut extracted_bytes = 0usize;
540
541 for (start, end) in merged_ranges.drain(..) {
542 if extracted_bytes >= LARGE_MACHO_LEGAL_MAX_EXTRACT_BYTES {
543 break;
544 }
545 let remaining = LARGE_MACHO_LEGAL_MAX_EXTRACT_BYTES - extracted_bytes;
546 let end = start.saturating_add((end - start).min(remaining));
547 let window_text = extract_printable_strings(&bytes[start..end]);
548 for line in window_text
549 .lines()
550 .map(str::trim)
551 .filter(|line| !line.is_empty())
552 {
553 combined_lines.insert(line.to_string());
554 }
555 extracted_bytes += end - start;
556 }
557
558 combined_lines.into_iter().collect::<Vec<_>>().join("\n")
559}
560
561fn collect_marker_window_ranges(bytes: &[u8], marker: &[u8], ranges: &mut Vec<(usize, usize)>) {
562 if marker.is_empty() || ranges.len() >= LARGE_MACHO_LEGAL_MAX_WINDOWS {
563 return;
564 }
565
566 let mut search_start = 0usize;
567 let mut hits_for_marker = 0usize;
568
569 while search_start + marker.len() <= bytes.len()
570 && ranges.len() < LARGE_MACHO_LEGAL_MAX_WINDOWS
571 && hits_for_marker < LARGE_MACHO_LEGAL_MAX_WINDOWS_PER_MARKER
572 {
573 let Some(relative_match) = bytes[search_start..].iter().position(|&b| b == marker[0])
574 else {
575 break;
576 };
577 let match_start = search_start + relative_match;
578 let match_end = match_start + marker.len();
579 if match_end <= bytes.len() && &bytes[match_start..match_end] == marker {
580 let half_window = LARGE_MACHO_LEGAL_WINDOW_BYTES / 2;
581 let window_start = match_start.saturating_sub(half_window);
582 let window_end = (match_end + half_window).min(bytes.len());
583 ranges.push((window_start, window_end));
584 hits_for_marker += 1;
585 search_start = match_end;
586 } else {
587 search_start = match_start + 1;
588 }
589 }
590}
591
592fn merge_overlapping_ranges(mut ranges: Vec<(usize, usize)>) -> Vec<(usize, usize)> {
593 if ranges.is_empty() {
594 return ranges;
595 }
596
597 ranges.sort_unstable_by_key(|&(start, end)| (start, end));
598
599 let mut merged = Vec::with_capacity(ranges.len());
600 let mut current = ranges[0];
601 for (start, end) in ranges.into_iter().skip(1) {
602 if start <= current.1 {
603 current.1 = current.1.max(end);
604 } else {
605 merged.push(current);
606 current = (start, end);
607 }
608 }
609 merged.push(current);
610
611 merged
612}
613
614fn sample_has_promising_printable_strings(bytes: &[u8]) -> bool {
615 let mut structured_signal_seen = false;
616 let promising_license_windows = sampled_printable_window_ranges(bytes.len())
617 .into_iter()
618 .filter(|&(start, end)| {
619 let window = &bytes[start..end];
620 if has_strong_structured_text_signal(window) {
621 structured_signal_seen = true;
622 }
623 has_license_or_notice_signal(window)
624 })
625 .count();
626
627 structured_signal_seen || promising_license_windows >= 2
628}
629
630fn extract_sampled_printable_strings(bytes: &[u8]) -> String {
631 let mut combined_lines = BTreeSet::new();
632
633 for (start, end) in sampled_printable_window_ranges(bytes.len()) {
634 let window_text = extract_printable_strings(&bytes[start..end]);
635 for line in window_text
636 .lines()
637 .map(str::trim)
638 .filter(|line| !line.is_empty())
639 {
640 combined_lines.insert(line.to_string());
641 }
642 }
643
644 combined_lines.into_iter().collect::<Vec<_>>().join("\n")
645}
646
647fn has_license_or_notice_signal(bytes: &[u8]) -> bool {
648 let strings = extract_printable_strings(bytes);
649 if strings.is_empty() {
650 return false;
651 }
652
653 let lower = strings.to_ascii_lowercase();
654 [
655 "copyright",
656 "license",
657 "licensed under",
658 "all rights reserved",
659 "permission is hereby granted",
660 "redistribution and use",
661 "spdx-license-identifier",
662 ]
663 .iter()
664 .any(|marker| lower.contains(marker))
665}
666
667fn has_strong_structured_text_signal(bytes: &[u8]) -> bool {
668 let strings = extract_printable_strings(bytes);
669 if strings.is_empty() {
670 return false;
671 }
672
673 let email_markers = strings.matches('@').count();
674 let url_markers = strings.matches("http://").count() + strings.matches("https://").count();
675
676 email_markers + url_markers >= 3
677}
678
679pub fn extract_printable_strings(bytes: &[u8]) -> String {
680 const MIN_LEN: usize = 4;
681 const MIN_OUTPUT_BYTES: usize = 2_000_000;
682 const MAX_OUTPUT_BYTES_CAP: usize = 16_000_000;
683
684 let max_output_bytes = bytes.len().clamp(MIN_OUTPUT_BYTES, MAX_OUTPUT_BYTES_CAP);
685
686 fn is_printable_ascii(b: u8) -> bool {
687 matches!(b, 0x20..=0x7E)
688 }
689
690 let mut out = String::new();
691 let mut run: Vec<u8> = Vec::new();
692
693 let flush_run = |out: &mut String, run: &mut Vec<u8>| {
694 if run.len() >= MIN_LEN {
695 if !out.is_empty() {
696 out.push('\n');
697 }
698 out.push_str(&String::from_utf8_lossy(run));
699 }
700 run.clear();
701 };
702
703 for &b in bytes {
704 if is_printable_ascii(b) {
705 run.push(b);
706 } else {
707 flush_run(&mut out, &mut run);
708 if out.len() >= max_output_bytes {
709 return out;
710 }
711 }
712 }
713 flush_run(&mut out, &mut run);
714 if out.len() >= max_output_bytes {
715 return out;
716 }
717
718 for start in 0..=1 {
719 run.clear();
720 let mut i = start;
721 while i + 1 < bytes.len() {
722 let b0 = bytes[i];
723 let b1 = bytes[i + 1];
724 let (ch, zero) = if start == 0 { (b0, b1) } else { (b1, b0) };
725 if is_printable_ascii(ch) && zero == 0 {
726 run.push(ch);
727 } else {
728 flush_run(&mut out, &mut run);
729 if out.len() >= max_output_bytes {
730 return out;
731 }
732 }
733 i += 2;
734 }
735 flush_run(&mut out, &mut run);
736 if out.len() >= max_output_bytes {
737 return out;
738 }
739 }
740
741 out
742}