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