PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.2
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.2
4.9.2 4.9.1 4.9.0 4.8.2 4.8.1 4.8.0 4.7.0 4.6.2 4.6.1 4.6.0 4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 4.5.1 4.5.0 4.4.1 4.4.0 3.3.4 3.4.0 3.4.1 3.4.2 3.5.0 3.5.1 All 200 releases
← All changes | includes/REST/WriteWithAI.php +395 -2 4.8.04.9.2 View file →
@@ -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