Skip to main content

provenant/utils/file/
extract.rs

1// SPDX-FileCopyrightText: nexB Inc. and others
2// SPDX-FileCopyrightText: Provenant contributors
3// SPDX-License-Identifier: Apache-2.0
4// Derived from ScanCode Toolkit (Apache-2.0); modified. See NOTICE.
5
6//! Text extraction for downstream license/copyright detection: chooses a
7//! strategy per input (RTF, PDF, image metadata, font metadata, decoded text,
8//! or bounded binary-string scraping) and augments markdown/HTML license hints.
9
10use 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        // Only surface the near-binary skip when nothing else recovered text and
299        // the file truly drops out of detection.
300        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}