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