| @@ -20,8 +20,13 @@ | ||
| 20 | 20 | |
| 21 | 21 | const MAX_SOURCE_LENGTH = 12000; |
| 22 | 22 | const MAX_PROMPT_LENGTH = 4000; |
| 23 | 23 | |
| 24 | + // "From Attachment" source: server-side text extraction from an uploaded file. | |
| 25 | + // 5 MB is generous for text/markdown/DOCX while capping abuse; the extracted | |
| 26 | + // text is still clipped to MAX_SOURCE_LENGTH before it reaches the model. | |
| 27 | + const MAX_UPLOAD_BYTES = 5242880; // 5 MB | |
| 28 | + | |
| 24 | 29 | public function register() { |
| 25 | 30 | $this->post( |
| 26 | 31 | '/write-with-ai', |
| 27 | 32 | array( $this, 'generate' ), |
| @@ -222,8 +227,51 @@ | ||
| 222 | 227 | . "\n---\n" . $source . "\n---" ) |
| 223 | 228 | . $this->build_directives( $tone, $doc_size, $generate_title ); |
| 224 | 229 | return $this->handle_doc( $write_ai, $post_id, $source_prompt, $keywords, $action, $doc_size, $extra_system ); |
| 225 | 230 | |
| 231 | + case 'from-attachment': | |
| 232 | + // Upload a file; extract its text server-side and treat it exactly | |
| 233 | + // like from-source (same "use only what it contains" contract and | |
| 234 | + // the same handle_doc → wp_kses_post output path). The file itself | |
| 235 | + // is never stored or rendered — only its extracted text is used as | |
| 236 | + // grounded prompt context. | |
| 237 | + $extracted = $this->read_uploaded_attachment( $request ); | |
| 238 | + if ( is_wp_error( $extracted ) ) { | |
| 239 | + return $this->error( $extracted->get_error_code() ?: 'ai_attachment_failed', $extracted->get_error_message(), 400 ); | |
| 240 | + } | |
| 241 | + | |
| 242 | + // Image attachment → send the picture to a vision-capable model | |
| 243 | + // instead of extracting text (there is none). Same handle_doc | |
| 244 | + // output path (wp_kses_post), just a multimodal request. | |
| 245 | + if ( isset( $extracted['kind'] ) && 'image' === $extracted['kind'] ) { | |
| 246 | + $image_prompt = trim( $prompt . "\n\n" | |
| 247 | + . sprintf( | |
| 248 | + /* translators: %s: the uploaded image file name. */ | |
| 249 | + __( 'Read the attached image "%s" and turn what it shows — its text, tables, diagrams, UI or screenshots — into structured documentation. Describe only what is actually visible in the image; do not invent details:', 'betterdocs' ), | |
| 250 | + $extracted['name'] | |
| 251 | + ) ) | |
| 252 | + . $this->build_directives( $tone, $doc_size, $generate_title ); | |
| 253 | + | |
| 254 | + return $this->handle_doc( $write_ai, $post_id, $image_prompt, $keywords, $action, $doc_size, $extra_system, $extracted ); | |
| 255 | + } | |
| 256 | + | |
| 257 | + // Extracted file text is prompt-bound source (not rendered as HTML), | |
| 258 | + // so preserve angle brackets like from-source/from-git do. | |
| 259 | + $file_text = $this->clip( wp_check_invalid_utf8( (string) $extracted['text'], true ), self::MAX_SOURCE_LENGTH ); | |
| 260 | + if ( '' === trim( $file_text ) ) { | |
| 261 | + return $this->error( 'ai_empty_attachment', __( 'No readable text was found in that file.', 'betterdocs' ), 400 ); | |
| 262 | + } | |
| 263 | + | |
| 264 | + $file_prompt = trim( $prompt . "\n\n" | |
| 265 | + . sprintf( | |
| 266 | + /* translators: %s: the uploaded file name. */ | |
| 267 | + __( 'Turn the content of the uploaded file "%s" into structured documentation. Use only the information it contains; do not invent details:', 'betterdocs' ), | |
| 268 | + $extracted['name'] | |
| 269 | + ) | |
| 270 | + . "\n---\n" . $file_text . "\n---" ) | |
| 271 | + . $this->build_directives( $tone, $doc_size, $generate_title ); | |
| 272 | + return $this->handle_doc( $write_ai, $post_id, $file_prompt, $keywords, $action, $doc_size, $extra_system ); | |
| 273 | + | |
| 226 | 274 | case 'git-repos': |
| 227 | 275 | case 'git-items': |
| 228 | 276 | case 'git-contents': |
| 229 | 277 | // "Browse repository" data for the From Git tab. Read-only listing |
| @@ -326,9 +374,9 @@ | ||
| 326 | 374 | |
| 327 | 375 | /** |
| 328 | 376 | * Full-doc generation (generate-doc, expand-outline, from-source all land here). |
| 329 | 377 | */ |
| 330 | - protected function handle_doc( $write_ai, $post_id, $prompt, $keywords, $action, $doc_size = 'any', $extra_system = array() ) { | |
| 378 | + protected function handle_doc( $write_ai, $post_id, $prompt, $keywords, $action, $doc_size = 'any', $extra_system = array(), $image = null ) { | |
| 331 | 379 | if ( '' === trim( $prompt ) ) { |
| 332 | 380 | return $this->error( 'ai_empty_prompt', __( 'Please provide a prompt for the AI.', 'betterdocs' ), 400 ); |
| 333 | 381 | } |
| 334 | 382 | |
| @@ -334,9 +382,20 @@ | ||
| 334 | 382 | |
| 335 | 383 | // A "long" doc can outrun the default 2500-token cap; give it headroom. |
| 336 | 384 | $max_tokens = 'long' === $doc_size ? 4000 : null; |
| 337 | 385 | |
| 338 | - $content = $write_ai->generate_openai_response( $prompt, $keywords, $max_tokens, $extra_system ); | |
| 386 | + if ( null !== $image ) { | |
| 387 | + // Image attachment: send the picture to a vision model. Returns a | |
| 388 | + // WP_Error when the configured model can't read images (guard) — surface | |
| 389 | + // that as a 400 so the user knows to switch models, not a 502. | |
| 390 | + $content = $write_ai->generate_vision_response( $prompt, $image, $max_tokens, $extra_system ); | |
| 391 | + if ( is_wp_error( $content ) ) { | |
| 392 | + $code = $content->get_error_code() ?: 'ai_vision_failed'; | |
| 393 | + return $this->error( $code, $content->get_error_message(), 'ai_no_vision' === $code ? 400 : 502 ); | |
| 394 | + } | |
| 395 | + } else { | |
| 396 | + $content = $write_ai->generate_openai_response( $prompt, $keywords, $max_tokens, $extra_system ); | |
| 397 | + } | |
| 339 | 398 | |
| 340 | 399 | if ( ! is_string( $content ) || '' === trim( $content ) ) { |
| 341 | 400 | return $this->error( 'empty', __( 'The AI returned no content. Try again or rephrase your prompt.', 'betterdocs' ), 502 ); |
| 342 | 401 | } |
| @@ -410,10 +469,344 @@ | ||
| 410 | 469 | } |
| 411 | 470 | return implode( "\n", $lines ); |
| 412 | 471 | } |
| 413 | 472 | |
| 473 | + /** | |
| 474 | + * Validate the uploaded "From Attachment" file and return its extracted text. | |
| 475 | + * | |
| 476 | + * Security: enforces is_uploaded_file (a real HTTP upload, not an arbitrary | |
| 477 | + * server path), a byte cap, and a strict extension + MIME allow-list via | |
| 478 | + * wp_check_filetype(). The file is read for text only — never moved into the | |
| 479 | + * uploads dir, stored, or rendered — so there is no persisted attack surface. | |
| 480 | + * | |
| 481 | + * @param WP_REST_Request $request | |
| 482 | + * @return array{name:string,text:string}|\WP_Error | |
| 483 | + */ | |
| 484 | + protected function read_uploaded_attachment( WP_REST_Request $request ) { | |
| 485 | + $files = $request->get_file_params(); | |
| 486 | + if ( empty( $files['file'] ) || ! is_array( $files['file'] ) ) { | |
| 487 | + return new \WP_Error( 'ai_no_file', __( 'No file was received. Choose a file to write from.', 'betterdocs' ) ); | |
| 488 | + } | |
| 489 | + | |
| 490 | + $file = $files['file']; | |
| 491 | + | |
| 492 | + if ( ! empty( $file['error'] ) || empty( $file['tmp_name'] ) || ! is_uploaded_file( $file['tmp_name'] ) ) { | |
| 493 | + return new \WP_Error( 'ai_upload_failed', __( 'The upload did not complete — please try again.', 'betterdocs' ) ); | |
| 494 | + } | |
| 495 | + | |
| 496 | + if ( (int) $file['size'] > self::MAX_UPLOAD_BYTES ) { | |
| 497 | + return new \WP_Error( | |
| 498 | + 'ai_file_too_large', | |
| 499 | + sprintf( | |
| 500 | + /* translators: %s: maximum allowed size, e.g. "5 MB". */ | |
| 501 | + __( 'The file exceeds the %s limit.', 'betterdocs' ), | |
| 502 | + size_format( self::MAX_UPLOAD_BYTES ) | |
| 503 | + ) | |
| 504 | + ); | |
| 505 | + } | |
| 506 | + | |
| 507 | + // Strict extension + MIME allow-list. wp_check_filetype() validates the | |
| 508 | + // name against exactly these types; anything else yields an empty ext. | |
| 509 | + $allowed = array( | |
| 510 | + 'txt' => 'text/plain', | |
| 511 | + 'md|markdown' => 'text/markdown', | |
| 512 | + 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', | |
| 513 | + 'pdf' => 'application/pdf', | |
| 514 | + 'png' => 'image/png', | |
| 515 | + 'jpg|jpeg' => 'image/jpeg', | |
| 516 | + 'webp' => 'image/webp', | |
| 517 | + ); | |
| 518 | + $check = wp_check_filetype( (string) $file['name'], $allowed ); | |
| 519 | + $ext = strtolower( (string) $check['ext'] ); | |
| 520 | + | |
| 521 | + $image_exts = array( 'png', 'jpg', 'jpeg', 'webp' ); | |
| 522 | + $text_exts = array( 'txt', 'md', 'markdown', 'docx', 'pdf' ); | |
| 523 | + | |
| 524 | + if ( ! in_array( $ext, array_merge( $text_exts, $image_exts ), true ) ) { | |
| 525 | + return new \WP_Error( 'ai_bad_filetype', __( 'Unsupported file type. Upload a .pdf, .docx, .txt, .md, or an image (.png, .jpg, .webp).', 'betterdocs' ) ); | |
| 526 | + } | |
| 527 | + | |
| 528 | + // Image → send the picture itself to a vision model (there is no text to | |
| 529 | + // extract). Verify it is a real image by its bytes, not just its name, | |
| 530 | + // then hand back a base64 data URI for the multimodal request. | |
| 531 | + if ( in_array( $ext, $image_exts, true ) ) { | |
| 532 | + $raw = file_get_contents( (string) $file['tmp_name'] ); // phpcs:ignore WordPressVIPMinimum.Performance.FetchingRemoteData.FileGetContentsUnknown -- local tmp upload. | |
| 533 | + if ( false === $raw || '' === $raw ) { | |
| 534 | + return new \WP_Error( 'ai_read_failed', __( 'Could not read the image file.', 'betterdocs' ) ); | |
| 535 | + } | |
| 536 | + | |
| 537 | + $info = @getimagesize( (string) $file['tmp_name'] ); | |
| 538 | + $mime = ( is_array( $info ) && ! empty( $info['mime'] ) ) ? (string) $info['mime'] : ''; | |
| 539 | + | |
| 540 | + if ( ! in_array( $mime, array( 'image/png', 'image/jpeg', 'image/webp' ), true ) ) { | |
| 541 | + return new \WP_Error( 'ai_bad_image', __( 'That file is not a valid PNG, JPG or WEBP image.', 'betterdocs' ) ); | |
| 542 | + } | |
| 543 | + | |
| 544 | + return array( | |
| 545 | + 'name' => sanitize_file_name( (string) $file['name'] ), | |
| 546 | + 'kind' => 'image', | |
| 547 | + 'mime' => $mime, | |
| 548 | + 'data_uri' => 'data:' . $mime . ';base64,' . base64_encode( $raw ), | |
| 549 | + ); | |
| 550 | + } | |
| 551 | + | |
| 552 | + $text = $this->extract_attachment_text( (string) $file['tmp_name'], $ext ); | |
| 553 | + if ( is_wp_error( $text ) ) { | |
| 554 | + return $text; | |
| 555 | + } | |
| 556 | + | |
| 557 | + return array( | |
| 558 | + 'name' => sanitize_file_name( (string) $file['name'] ), | |
| 559 | + 'kind' => 'text', | |
| 560 | + 'text' => $text, | |
| 561 | + ); | |
| 562 | + } | |
| 563 | + | |
| 564 | + /** | |
| 565 | + * Extract plain text from a supported uploaded file. TXT/MD are read as-is; | |
| 566 | + * DOCX is unzipped natively (ZipArchive) and its document body flattened; | |
| 567 | + * PDF text is pulled natively from FlateDecode content streams — all without | |
| 568 | + * a third-party parser dependency. Images/scanned PDFs (no embedded text) are | |
| 569 | + * not handled here (that would need OCR / a multimodal model). | |
| 570 | + * | |
| 571 | + * @param string $path Local tmp upload path (already is_uploaded_file-verified). | |
| 572 | + * @param string $ext Allow-listed extension. | |
| 573 | + * @return string|\WP_Error | |
| 574 | + */ | |
| 575 | + protected function extract_attachment_text( $path, $ext ) { | |
| 576 | + if ( in_array( $ext, array( 'txt', 'md', 'markdown' ), true ) ) { | |
| 577 | + $raw = file_get_contents( $path ); // phpcs:ignore WordPressVIPMinimum.Performance.FetchingRemoteData.FileGetContentsUnknown -- local tmp upload. | |
| 578 | + return false === $raw ? new \WP_Error( 'ai_read_failed', __( 'Could not read the file.', 'betterdocs' ) ) : $raw; | |
| 579 | + } | |
| 580 | + | |
| 581 | + if ( 'docx' === $ext ) { | |
| 582 | + if ( ! class_exists( '\ZipArchive' ) ) { | |
| 583 | + return new \WP_Error( 'ai_no_zip', __( 'Reading .docx files needs the PHP Zip extension, which is not available on this server. Upload a .txt or .md instead.', 'betterdocs' ) ); | |
| 584 | + } | |
| 585 | + $zip = new \ZipArchive(); | |
| 586 | + if ( true !== $zip->open( $path ) ) { | |
| 587 | + return new \WP_Error( 'ai_bad_docx', __( 'Could not open that .docx file — it may be corrupt.', 'betterdocs' ) ); | |
| 588 | + } | |
| 589 | + $xml = $zip->getFromName( 'word/document.xml' ); | |
| 590 | + $zip->close(); | |
| 591 | + | |
| 592 | + if ( false === $xml || '' === $xml ) { | |
| 593 | + return new \WP_Error( 'ai_bad_docx', __( 'That .docx file has no readable document body.', 'betterdocs' ) ); | |
| 594 | + } | |
| 595 | + | |
| 596 | + // Turn Word paragraph/break/tab elements into whitespace, then strip | |
| 597 | + // every remaining tag so only the run text (<w:t>) survives, and decode | |
| 598 | + // XML entities. Keeps paragraph structure the model can read. | |
| 599 | + $xml = preg_replace( '#</w:p>#', "\n\n", $xml ); | |
| 600 | + $xml = preg_replace( '#<w:br\b[^>]*/?>#', "\n", $xml ); | |
| 601 | + $xml = preg_replace( '#<w:tab\b[^>]*/?>#', "\t", $xml ); | |
| 602 | + $text = wp_strip_all_tags( (string) $xml ); | |
| 603 | + $text = html_entity_decode( $text, ENT_QUOTES | ENT_XML1, 'UTF-8' ); | |
| 604 | + | |
| 605 | + return trim( preg_replace( "/\n{3,}/", "\n\n", $text ) ); | |
| 606 | + } | |
| 607 | + | |
| 608 | + if ( 'pdf' === $ext ) { | |
| 609 | + return $this->extract_pdf_text( $path ); | |
| 610 | + } | |
| 611 | + | |
| 612 | + return new \WP_Error( 'ai_bad_filetype', __( 'Unsupported file type.', 'betterdocs' ) ); | |
| 613 | + } | |
| 614 | + | |
| 615 | + /** | |
| 616 | + * Extract text from a PDF natively — no library. PDFs keep their page text in | |
| 617 | + * "content streams" (usually zlib/FlateDecode-compressed); we inflate each one | |
| 618 | + * and pull the operands of the text-showing operators (Tj / TJ / ' / "). This | |
| 619 | + * covers the common case (real, text-based documents). It intentionally does | |
| 620 | + * NOT handle: | |
| 621 | + * - encrypted PDFs (no key) — reported so the user knows why, | |
| 622 | + * - scanned/image-only PDFs (there is no embedded text to read) — reported, | |
| 623 | + * - exotic font encodings (CID/Type0 with custom CMaps) — those decode to | |
| 624 | + * garbled text, so we drop a stream whose result looks non-textual. | |
| 625 | + * The extracted text is prompt-bound source only; it is never rendered, and | |
| 626 | + * the model output still passes wp_kses_post downstream. | |
| 627 | + * | |
| 628 | + * @param string $path Local tmp upload path. | |
| 629 | + * @return string|\WP_Error | |
| 630 | + */ | |
| 631 | + protected function extract_pdf_text( $path ) { | |
| 632 | + $data = file_get_contents( $path ); // phpcs:ignore WordPressVIPMinimum.Performance.FetchingRemoteData.FileGetContentsUnknown -- local tmp upload. | |
| 633 | + if ( false === $data || 0 !== strncmp( $data, '%PDF', 4 ) ) { | |
| 634 | + return new \WP_Error( 'ai_bad_pdf', __( 'That does not look like a valid PDF file.', 'betterdocs' ) ); | |
| 635 | + } | |
| 636 | + | |
| 637 | + // An encrypted PDF's streams won't inflate to readable text without the | |
| 638 | + // key. Detect the Encrypt entry up front and say so, rather than return | |
| 639 | + // empty. (An /Encrypt inside a literal string is a rare false positive we | |
| 640 | + // accept — worst case the user gets the "no text" message below instead.) | |
| 641 | + if ( preg_match( '/\/Encrypt\b/', $data ) ) { | |
| 642 | + return new \WP_Error( 'ai_pdf_encrypted', __( 'This PDF is password-protected or encrypted, so its text can\'t be read. Remove the protection, or paste the text instead.', 'betterdocs' ) ); | |
| 643 | + } | |
| 644 | + | |
| 645 | + $out = ''; | |
| 646 | + $cap = self::MAX_SOURCE_LENGTH + 4000; // stop early; the source is clipped later anyway. | |
| 647 | + | |
| 648 | + if ( preg_match_all( '/stream\r?\n(.*?)\r?\nendstream/s', $data, $streams ) ) { | |
| 649 | + foreach ( $streams[1] as $chunk ) { | |
| 650 | + // Try zlib (FlateDecode) first, then raw-deflate, then treat as | |
| 651 | + // already-plain. @-silenced: a binary (image/font) stream simply | |
| 652 | + // fails to inflate and is skipped below. | |
| 653 | + $decoded = @gzuncompress( $chunk ); | |
| 654 | + if ( false === $decoded ) { | |
| 655 | + $decoded = @gzinflate( $chunk ); | |
| 656 | + } | |
| 657 | + $content = ( is_string( $decoded ) && '' !== $decoded ) ? $decoded : $chunk; | |
| 658 | + | |
| 659 | + // Only content streams carry text-showing operators; skip the rest | |
| 660 | + // (images, fonts) so we don't scrape binary noise. | |
| 661 | + if ( false === strpos( $content, 'Tj' ) && false === strpos( $content, 'TJ' ) ) { | |
| 662 | + continue; | |
| 663 | + } | |
| 664 | + | |
| 665 | + $piece = $this->pdf_stream_text( $content ); | |
| 666 | + // Guard against garbled CID/font-encoded streams: if the decoded | |
| 667 | + // "text" is mostly non-printable, drop it rather than inject noise. | |
| 668 | + if ( '' !== $piece && $this->mostly_printable( $piece ) ) { | |
| 669 | + $out .= $piece . "\n"; | |
| 670 | + if ( strlen( $out ) > $cap ) { | |
| 671 | + break; | |
| 672 | + } | |
| 673 | + } | |
| 674 | + } | |
| 675 | + } | |
| 676 | + | |
| 677 | + $out = preg_replace( "/[ \t]+/", ' ', $out ); | |
| 678 | + $out = trim( preg_replace( "/\n{3,}/", "\n\n", $out ) ); | |
| 679 | + | |
| 680 | + // Subsetted LaTeX/CID fonts emit control bytes (ligatures) and non-UTF-8 | |
| 681 | + // sequences among the readable text. Strip them and coerce to valid UTF-8 — | |
| 682 | + // otherwise the caller's wp_check_invalid_utf8() discards the ENTIRE string | |
| 683 | + // on the first bad byte and an 8-page paper looks empty ("no readable text"). | |
| 684 | + $out = $this->to_clean_utf8( $out ); | |
| 685 | + | |
| 686 | + if ( '' === $out ) { | |
| 687 | + return new \WP_Error( | |
| 688 | + 'ai_pdf_no_text', | |
| 689 | + __( 'No selectable text was found in that PDF — it may be a scanned image. Try a text-based PDF, or paste the content into the prompt.', 'betterdocs' ) | |
| 690 | + ); | |
| 691 | + } | |
| 692 | + | |
| 693 | + return $out; | |
| 694 | + } | |
| 695 | + | |
| 696 | + /** | |
| 697 | + * Pull the visible text out of one decoded PDF content stream. Positioning | |
| 698 | + * operators (Td/TD/T*) become newlines; the literal `( … )` and hex `< … >` | |
| 699 | + * operands of Tj/TJ/'/'' become the text. Kerning numbers inside TJ arrays are | |
| 700 | + * ignored (their effect on spacing is cosmetic for our purposes). | |
| 701 | + */ | |
| 702 | + protected function pdf_stream_text( $content ) { | |
| 703 | + // Text-positioning operators (new line / new paragraph) become newlines so | |
| 704 | + // words on different lines don't run together. | |
| 705 | + $content = preg_replace( '/\b(?:T\*|Td|TD)\b/', " \n ", $content ); | |
| 706 | + | |
| 707 | + // Walk TJ arrays and Tj/'/'" strings in document order. Inside a TJ array | |
| 708 | + // pdfTeX (LaTeX) renders an inter-word space as a large negative kerning | |
| 709 | + // number, not a literal space in the string — so we synthesise a space when | |
| 710 | + // the kerning passes a threshold, otherwise every word runs together | |
| 711 | + // ("FormallyVerifiedand…"). Small kerning (letter pairs) is ignored. | |
| 712 | + if ( ! preg_match_all( | |
| 713 | + '/\[((?:\\\\.|[^\]\\\\])*)\]\s*TJ|(\((?:\\\\.|[^\\\\()])*\)|<[0-9A-Fa-f\s]+>)\s*(?:Tj|\'|")|(\n)/s', | |
| 714 | + $content, | |
| 715 | + $matches, | |
| 716 | + PREG_SET_ORDER | |
| 717 | + ) ) { | |
| 718 | + return ''; | |
| 719 | + } | |
| 720 | + | |
| 721 | + $text = ''; | |
| 722 | + foreach ( $matches as $tok ) { | |
| 723 | + if ( isset( $tok[3] ) && "\n" === $tok[3] ) { | |
| 724 | + $text .= "\n"; | |
| 725 | + continue; | |
| 726 | + } | |
| 727 | + if ( isset( $tok[1] ) && '' !== $tok[1] ) { | |
| 728 | + // TJ array: alternating string operands and kerning numbers. | |
| 729 | + preg_match_all( '/\((?:\\\\.|[^\\\\()])*\)|<[0-9A-Fa-f\s]+>|-?\d+(?:\.\d+)?/s', $tok[1], $parts ); | |
| 730 | + foreach ( $parts[0] as $part ) { | |
| 731 | + if ( '(' === $part[0] || '<' === $part[0] ) { | |
| 732 | + $text .= $this->pdf_token_text( $part ); | |
| 733 | + } elseif ( (float) $part < -100 ) { | |
| 734 | + $text .= ' '; | |
| 735 | + } | |
| 736 | + } | |
| 737 | + $text .= ' '; | |
| 738 | + } elseif ( isset( $tok[2] ) && '' !== $tok[2] ) { | |
| 739 | + $text .= $this->pdf_token_text( $tok[2] ) . ' '; | |
| 740 | + } | |
| 741 | + } | |
| 742 | + | |
| 743 | + return preg_replace( '/[^\S\n]+/', ' ', $text ); | |
| 744 | + } | |
| 745 | + | |
| 746 | + /** | |
| 747 | + * Decode one PDF string operand — a literal `( … )` (with escapes) or a hex | |
| 748 | + * `< … >` string — into its raw bytes. | |
| 749 | + */ | |
| 750 | + protected function pdf_token_text( $token ) { | |
| 751 | + if ( '(' === $token[0] ) { | |
| 752 | + return $this->pdf_unescape( substr( $token, 1, -1 ) ); | |
| 753 | + } | |
| 754 | + $hex = preg_replace( '/[^0-9A-Fa-f]/', '', $token ); | |
| 755 | + return ( '' === $hex ) ? '' : (string) @hex2bin( strlen( $hex ) % 2 ? substr( $hex, 0, -1 ) : $hex ); | |
| 756 | + } | |
| 757 | + | |
| 758 | + /** | |
| 759 | + * Resolve PDF string escapes: \( \) \\ \n \r \t \b \f and \ddd octal codes. | |
| 760 | + */ | |
| 761 | + protected function pdf_unescape( $string ) { | |
| 762 | + return preg_replace_callback( | |
| 763 | + '/\\\\(?:([nrtbf()\\\\])|([0-7]{1,3}))/', | |
| 764 | + function ( $mm ) { | |
| 765 | + if ( isset( $mm[1] ) && '' !== $mm[1] ) { | |
| 766 | + $map = array( 'n' => "\n", 'r' => "\r", 't' => "\t", 'b' => "\x08", 'f' => "\x0C", '(' => '(', ')' => ')', '\\' => '\\' ); | |
| 767 | + return isset( $map[ $mm[1] ] ) ? $map[ $mm[1] ] : $mm[1]; | |
| 768 | + } | |
| 769 | + return chr( octdec( $mm[2] ) & 0xFF ); | |
| 770 | + }, | |
| 771 | + $string | |
| 772 | + ); | |
| 773 | + } | |
| 774 | + | |
| 775 | + /** | |
| 776 | + * Is this decoded string mostly readable text? Used to drop font/CID streams | |
| 777 | + * that decode to binary-looking garbage. Counts printable + common whitespace. | |
| 778 | + */ | |
| 779 | + protected function mostly_printable( $string ) { | |
| 780 | + $len = strlen( $string ); | |
| 781 | + if ( 0 === $len ) { | |
| 782 | + return false; | |
| 783 | + } | |
| 784 | + $printable = preg_match_all( '/[\P{Cc}\t\n\r]/u', $string ); | |
| 785 | + // Fallback for non-UTF-8 payloads where \p{} may not match cleanly. | |
| 786 | + if ( false === $printable ) { | |
| 787 | + $printable = strlen( preg_replace( '/[^\x09\x0A\x0D\x20-\x7E]/', '', $string ) ); | |
| 788 | + } | |
| 789 | + return ( $printable / $len ) >= 0.7; | |
| 790 | + } | |
| 791 | + | |
| 414 | 792 | protected function clip( $value, $max ) { |
| 415 | 793 | return strlen( $value ) > $max ? substr( $value, 0, $max ) : $value; |
| 794 | + } | |
| 795 | + | |
| 796 | + /** | |
| 797 | + * Coerce extracted PDF bytes to clean, valid UTF-8: drop C0/C1 control bytes | |
| 798 | + * (except tab/newline) and any byte sequence that isn't valid UTF-8. This keeps | |
| 799 | + * the readable text intact for the downstream wp_check_invalid_utf8(), which | |
| 800 | + * would otherwise discard the whole string on a single invalid byte. | |
| 801 | + */ | |
| 802 | + protected function to_clean_utf8( $string ) { | |
| 803 | + $string = preg_replace( '/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', (string) $string ); | |
| 804 | + if ( '' !== $string && ! preg_match( '//u', $string ) ) { | |
| 805 | + $converted = @iconv( 'UTF-8', 'UTF-8//IGNORE', $string ); | |
| 806 | + $string = ( false !== $converted ) ? $converted : preg_replace( '/[^\x09\x0A\x20-\x7E]/', '', $string ); | |
| 807 | + } | |
| 808 | + return (string) $string; | |
| 416 | 809 | } |
| 417 | 810 | |
| 418 | 811 | /** |
| 419 | 812 | * Frame the user's free-form request as a documentation instruction. Returns |