diff --git a/browser/ai_chat/BUILD.gn b/browser/ai_chat/BUILD.gn index 80d9692b074..f9adf9cc9f3 100644 --- a/browser/ai_chat/BUILD.gn +++ b/browser/ai_chat/BUILD.gn @@ -66,6 +66,7 @@ static_library("ai_chat") { "//chrome/browser/actor", "//chrome/browser/profiles:profile", "//chrome/browser/ui/tabs:tabs_public", + "//chrome/common", "//chrome/common:channel_info", "//components/browsing_data/core", "//components/keyed_service/content", @@ -76,6 +77,7 @@ static_library("ai_chat") { "//printing:printing_base", "//services/data_decoder/public/cpp", "//services/network/public/cpp", + "//third_party/blink/public/common", "//ui/base", "//ui/shell_dialogs", ] @@ -84,7 +86,14 @@ static_library("ai_chat") { deps += [ "//brave/build/android:jni_headers" ] } if (!is_android) { - deps += [ "//chrome/browser/ui/side_panel" ] + sources += [ + "text_file_extractor.cc", + "text_file_extractor.h", + ] + deps += [ + "//chrome/browser/ui/side_panel", + "//third_party/blink/public/strings", + ] } if (enable_pdf) { @@ -289,6 +298,10 @@ source_set("unit_tests") { sources += [ "tools/tab_management_tool_unittest.cc" ] } + if (!is_android) { + sources += [ "text_file_extractor_unittest.cc" ] + } + if (enable_pdf) { sources += [ "pdf_text_extractor_unittest.cc" ] } @@ -357,6 +370,7 @@ source_set("browser_tests") { "ai_chat_ui_browsertest.cc", "code_execution_tool_browsertest.cc", "page_content_fetcher_browsertest.cc", + "text_file_extractor_browsertest.cc", ] if (enable_pdf) { diff --git a/browser/ai_chat/DEPS b/browser/ai_chat/DEPS index d9a81c84e05..e3cea864cae 100644 --- a/browser/ai_chat/DEPS +++ b/browser/ai_chat/DEPS @@ -2,6 +2,7 @@ include_rules = [ "+brave/components/restricted_web_contents_delegate", "+brave/services/printing/public/mojom", "+brave/components/text_recognition/common", + "+third_party/blink/public/strings", ] specific_include_rules = { diff --git a/browser/ai_chat/file_text_extractor_base.cc b/browser/ai_chat/file_text_extractor_base.cc index 2272b30ad58..f2b8bda0696 100644 --- a/browser/ai_chat/file_text_extractor_base.cc +++ b/browser/ai_chat/file_text_extractor_base.cc @@ -64,11 +64,34 @@ FileTextExtractorBase::~FileTextExtractorBase() { Cleanup(); } +void FileTextExtractorBase::ExtractText( + content::BrowserContext* browser_context, + const base::FilePath& file_path, + ExtractTextCallback callback) { + CHECK(!callback_) << "ExtractText called while extraction in progress"; + callback_ = std::move(callback); + LoadInWebContents(browser_context, file_path); +} + +void FileTextExtractorBase::ExtractText( + content::BrowserContext* browser_context, + std::vector file_bytes, + const base::FilePath::StringType& extension, + ExtractTextCallback callback) { + CHECK(!callback_) << "ExtractText called while extraction in progress"; + callback_ = std::move(callback); + WriteTempFileAndLoad(browser_context, std::move(file_bytes), extension); +} + network::mojom::WebSandboxFlags FileTextExtractorBase::AdditionalUnsandboxFlags() const { return network::mojom::WebSandboxFlags::kNone; } +GURL FileTextExtractorBase::GetLoadURL(const base::FilePath& file_path) const { + return net::FilePathToFileURL(file_path); +} + void FileTextExtractorBase::LoadInWebContents( content::BrowserContext* browser_context, const base::FilePath& file_path) { @@ -89,8 +112,8 @@ void FileTextExtractorBase::LoadInWebContents( base::BindOnce(&FileTextExtractorBase::OnTimeout, base::Unretained(this))); - const GURL file_url = net::FilePathToFileURL(file_path); - web_contents_->GetController().LoadURL(file_url, content::Referrer(), + const GURL load_url = GetLoadURL(file_path); + web_contents_->GetController().LoadURL(load_url, content::Referrer(), ui::PAGE_TRANSITION_AUTO_TOPLEVEL, std::string()); } diff --git a/browser/ai_chat/file_text_extractor_base.h b/browser/ai_chat/file_text_extractor_base.h index b800760cb39..209fead5220 100644 --- a/browser/ai_chat/file_text_extractor_base.h +++ b/browser/ai_chat/file_text_extractor_base.h @@ -18,6 +18,7 @@ #include "brave/components/restricted_web_contents_delegate/restricted_web_contents_delegate.h" #include "content/public/browser/web_contents_observer.h" #include "services/network/public/cpp/web_sandbox_flags.h" +#include "url/gurl.h" namespace content { class BrowserContext; @@ -44,6 +45,21 @@ class FileTextExtractorBase : public RestrictedWebContentsDelegate, FileTextExtractorBase(const FileTextExtractorBase&) = delete; FileTextExtractorBase& operator=(const FileTextExtractorBase&) = delete; + // Two entry points for text extraction: + + // Use an existing file path directly (e.g. from file picker). + void ExtractText(content::BrowserContext* browser_context, + const base::FilePath& file_path, + ExtractTextCallback callback); + + // Write bytes to a temp file first (e.g. from drag-and-drop). + // |extension| is the file extension for MIME type detection (without + // leading dot). + void ExtractText(content::BrowserContext* browser_context, + std::vector file_bytes, + const base::FilePath::StringType& extension, + ExtractTextCallback callback); + protected: // Called when the document has loaded and is ready for text extraction. // Subclasses must implement this and call Finish() with the result. @@ -53,6 +69,10 @@ class FileTextExtractorBase : public RestrictedWebContentsDelegate, // (Scripts, Origin, Navigation). Override to add more, e.g. kPlugins. virtual network::mojom::WebSandboxFlags AdditionalUnsandboxFlags() const; + // Returns the URL to load for the given file path. Default returns a + // file:// URL. Override to customize, e.g. view-source:file://. + virtual GURL GetLoadURL(const base::FilePath& file_path) const; + // Starts loading a file in a hidden WebContents. void LoadInWebContents(content::BrowserContext* browser_context, const base::FilePath& file_path); diff --git a/browser/ai_chat/pdf_text_extractor.cc b/browser/ai_chat/pdf_text_extractor.cc index 9b0f8c85049..0647f2c3408 100644 --- a/browser/ai_chat/pdf_text_extractor.cc +++ b/browser/ai_chat/pdf_text_extractor.cc @@ -19,23 +19,6 @@ PdfTextExtractor::PdfTextExtractor() = default; PdfTextExtractor::~PdfTextExtractor() = default; -void PdfTextExtractor::ExtractText(content::BrowserContext* browser_context, - const base::FilePath& pdf_path, - ExtractTextCallback callback) { - CHECK(!callback_) << "ExtractText called while extraction in progress"; - callback_ = std::move(callback); - LoadInWebContents(browser_context, pdf_path); -} - -void PdfTextExtractor::ExtractText(content::BrowserContext* browser_context, - std::vector pdf_bytes, - ExtractTextCallback callback) { - CHECK(!callback_) << "ExtractText called while extraction in progress"; - callback_ = std::move(callback); - WriteTempFileAndLoad(browser_context, std::move(pdf_bytes), - FILE_PATH_LITERAL("pdf")); -} - network::mojom::WebSandboxFlags PdfTextExtractor::AdditionalUnsandboxFlags() const { // Plugins are required for the PDF viewer MimeHandlerView. diff --git a/browser/ai_chat/pdf_text_extractor.h b/browser/ai_chat/pdf_text_extractor.h index a7cc0fa84d4..22b61547b6f 100644 --- a/browser/ai_chat/pdf_text_extractor.h +++ b/browser/ai_chat/pdf_text_extractor.h @@ -6,16 +6,11 @@ #ifndef BRAVE_BROWSER_AI_CHAT_PDF_TEXT_EXTRACTOR_H_ #define BRAVE_BROWSER_AI_CHAT_PDF_TEXT_EXTRACTOR_H_ -#include -#include -#include - #include "base/memory/weak_ptr.h" #include "brave/browser/ai_chat/file_text_extractor_base.h" #include "services/network/public/cpp/web_sandbox_flags.h" namespace content { -class BrowserContext; class RenderFrameHost; } // namespace content @@ -25,28 +20,12 @@ namespace ai_chat { // The PDF viewer extension + ScreenAI OCR pipeline runs, then page text is // extracted via PDFDocumentHelper::GetPageText(). // -// Two entry points: -// - ExtractText(browser_context, pdf_path, callback) -// Uses an existing file path directly (e.g. from file picker). -// - ExtractText(browser_context, pdf_bytes, callback) -// Writes bytes to a temp file first (e.g. from drag-and-drop). -// // The extractor should be kept alive until the callback fires. class PdfTextExtractor : public FileTextExtractorBase { public: PdfTextExtractor(); ~PdfTextExtractor() override; - // Use an existing file path directly (no temp file created). - void ExtractText(content::BrowserContext* browser_context, - const base::FilePath& pdf_path, - ExtractTextCallback callback); - - // Write bytes to a temp file first, then extract. - void ExtractText(content::BrowserContext* browser_context, - std::vector pdf_bytes, - ExtractTextCallback callback); - private: // FileTextExtractorBase: void OnDocumentReady() override; diff --git a/browser/ai_chat/pdf_text_extractor_browsertest.cc b/browser/ai_chat/pdf_text_extractor_browsertest.cc index 8bd4cf0a24e..c26402dedb8 100644 --- a/browser/ai_chat/pdf_text_extractor_browsertest.cc +++ b/browser/ai_chat/pdf_text_extractor_browsertest.cc @@ -60,7 +60,7 @@ IN_PROC_BROWSER_TEST_F(PdfTextExtractorBrowserTest, base::test::TestFuture> future; extractor->ExtractText(browser_context(), std::move(*pdf_bytes), - future.GetCallback()); + FILE_PATH_LITERAL("pdf"), future.GetCallback()); auto result = future.Take(); ASSERT_TRUE(result.has_value()); diff --git a/browser/ai_chat/pdf_text_extractor_unittest.cc b/browser/ai_chat/pdf_text_extractor_unittest.cc index 16d6a470eca..31d13bf4515 100644 --- a/browser/ai_chat/pdf_text_extractor_unittest.cc +++ b/browser/ai_chat/pdf_text_extractor_unittest.cc @@ -40,7 +40,7 @@ TEST_F(PdfTextExtractorTest, BytesOverload_TimeoutReturnsNullopt) { std::vector dummy_pdf = {0x25, 0x50, 0x44, 0x46}; // %PDF extractor->ExtractText(browser_context(), std::move(dummy_pdf), - future.GetCallback()); + FILE_PATH_LITERAL("pdf"), future.GetCallback()); // Fast-forward past the 30s extraction timeout. // This also processes pending ThreadPool tasks (temp-file write). @@ -99,7 +99,7 @@ TEST_F(PdfTextExtractorTest, BytesOverload_CleanupAfterTimeout) { std::vector dummy_pdf = {0x25, 0x50, 0x44, 0x46}; extractor->ExtractText(browser_context(), std::move(dummy_pdf), - future.GetCallback()); + FILE_PATH_LITERAL("pdf"), future.GetCallback()); // Fast-forward past timeout to trigger cleanup. // This also processes pending ThreadPool tasks (temp-file write). diff --git a/browser/ai_chat/text_file_extractor.cc b/browser/ai_chat/text_file_extractor.cc new file mode 100644 index 00000000000..f71534bb52f --- /dev/null +++ b/browser/ai_chat/text_file_extractor.cc @@ -0,0 +1,71 @@ +// Copyright (c) 2026 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +#include "brave/browser/ai_chat/text_file_extractor.h" + +#include + +#include "base/functional/bind.h" +#include "base/logging.h" +#include "base/strings/strcat.h" +#include "base/strings/string_util.h" +#include "chrome/common/chrome_isolated_world_ids.h" +#include "content/public/browser/render_frame_host.h" +#include "content/public/browser/web_contents.h" +#include "content/public/common/url_constants.h" +#include "net/base/filename_util.h" +#include "third_party/blink/public/strings/grit/blink_strings.h" +#include "ui/base/l10n/l10n_util.h" + +namespace ai_chat { + +TextFileExtractor::TextFileExtractor() = default; + +TextFileExtractor::~TextFileExtractor() = default; + +GURL TextFileExtractor::GetLoadURL(const base::FilePath& file_path) const { + // Use view-source: to prevent HTML/XHTML from being rendered (which could + // execute scripts or load external resources). The source is displayed as + // raw text in the view-source document. + return GURL(base::StrCat({content::kViewSourceScheme, ":", + net::FilePathToFileURL(file_path).spec()})); +} + +void TextFileExtractor::OnDocumentReady() { + auto* rfh = GetWebContents()->GetPrimaryMainFrame(); + if (!rfh) { + Finish(std::nullopt); + return; + } + + rfh->ExecuteJavaScriptInIsolatedWorld( + u"document.body.innerText", + base::BindOnce(&TextFileExtractor::OnTextExtracted, + weak_ptr_factory_.GetWeakPtr()), + ISOLATED_WORLD_ID_BRAVE_INTERNAL); +} + +void TextFileExtractor::OnTextExtracted(base::Value result) { + if (result.is_string()) { + // view-source: innerText starts with the localized "Line wrap" label + // followed by a newline, and indents each line with a leading tab. + // Strip the label prefix and the per-line tabs. + // For non-empty files the prefix is "Line wrap\n\t", for empty files + // it's just "Line wrap" with no trailing newline or tab. + std::string text = std::move(result).TakeString(); + std::string line_wrap_label = + l10n_util::GetStringUTF8(IDS_VIEW_SOURCE_LINE_WRAP); + if (auto rest = base::RemovePrefix(text, line_wrap_label)) { + text = std::string(base::RemovePrefix(*rest, "\n\t").value_or(*rest)); + } + base::ReplaceSubstringsAfterOffset(&text, 0, "\n\t", "\n"); + Finish(std::move(text)); + } else { + DVLOG(1) << "TextFileExtractor: JS returned non-string result"; + Finish(std::nullopt); + } +} + +} // namespace ai_chat diff --git a/browser/ai_chat/text_file_extractor.h b/browser/ai_chat/text_file_extractor.h new file mode 100644 index 00000000000..d573bf39d2c --- /dev/null +++ b/browser/ai_chat/text_file_extractor.h @@ -0,0 +1,45 @@ +// Copyright (c) 2026 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +#ifndef BRAVE_BROWSER_AI_CHAT_TEXT_FILE_EXTRACTOR_H_ +#define BRAVE_BROWSER_AI_CHAT_TEXT_FILE_EXTRACTOR_H_ + +#include "base/gtest_prod_util.h" +#include "base/memory/weak_ptr.h" +#include "brave/browser/ai_chat/file_text_extractor_base.h" + +namespace ai_chat { + +// Extracts text from a file by loading it in a hidden background WebContents. +// Chromium's renderer handles MIME sniffing and renders the file content, +// then text is extracted via document.body.innerText. +// +// The extractor should be kept alive until the callback fires. +class TextFileExtractor : public FileTextExtractorBase { + public: + TextFileExtractor(); + ~TextFileExtractor() override; + + private: + FRIEND_TEST_ALL_PREFIXES(TextFileExtractorTest, + OnTextExtracted_StripsViewSourcePrefix); + FRIEND_TEST_ALL_PREFIXES(TextFileExtractorTest, + OnTextExtracted_StripsLeadingTabs); + FRIEND_TEST_ALL_PREFIXES(TextFileExtractorTest, OnTextExtracted_EmptyFile); + FRIEND_TEST_ALL_PREFIXES(TextFileExtractorTest, + OnTextExtracted_NonStringReturnsNullopt); + + // FileTextExtractorBase: + GURL GetLoadURL(const base::FilePath& file_path) const override; + void OnDocumentReady() override; + + void OnTextExtracted(base::Value result); + + base::WeakPtrFactory weak_ptr_factory_{this}; +}; + +} // namespace ai_chat + +#endif // BRAVE_BROWSER_AI_CHAT_TEXT_FILE_EXTRACTOR_H_ diff --git a/browser/ai_chat/text_file_extractor_browsertest.cc b/browser/ai_chat/text_file_extractor_browsertest.cc new file mode 100644 index 00000000000..d72e0e48dd4 --- /dev/null +++ b/browser/ai_chat/text_file_extractor_browsertest.cc @@ -0,0 +1,139 @@ +// Copyright (c) 2026 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +#include "brave/browser/ai_chat/text_file_extractor.h" + +#include +#include +#include + +#include "base/files/file_util.h" +#include "base/path_service.h" +#include "base/test/test_future.h" +#include "base/threading/thread_restrictions.h" +#include "brave/components/constants/brave_paths.h" +#include "chrome/browser/profiles/profile.h" +#include "chrome/browser/ui/browser.h" +#include "chrome/test/base/in_process_browser_test.h" +#include "content/public/test/browser_test.h" +#include "testing/gtest/include/gtest/gtest.h" + +namespace ai_chat { + +// Expected content of test/data/leo/dummy.txt +constexpr char kExpectedTextContent[] = + "Hello from a text file.\nThis is line two."; + +class TextFileExtractorBrowserTest : public InProcessBrowserTest { + protected: + base::FilePath GetTestFilePath(std::string_view filename) { + base::ScopedAllowBlockingForTesting allow_blocking; + return base::PathService::CheckedGet(brave::DIR_TEST_DATA) + .AppendASCII("leo") + .AppendASCII(filename); + } + + content::BrowserContext* browser_context() { return browser()->profile(); } +}; + +IN_PROC_BROWSER_TEST_F(TextFileExtractorBrowserTest, + PathOverload_ExtractsText) { + base::FilePath txt_path = GetTestFilePath("dummy.txt"); + + auto extractor = std::make_unique(); + base::test::TestFuture> future; + + extractor->ExtractText(browser_context(), txt_path, future.GetCallback()); + + auto result = future.Take(); + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(*result, kExpectedTextContent); +} + +IN_PROC_BROWSER_TEST_F(TextFileExtractorBrowserTest, + BytesOverload_ExtractsText) { + std::optional> file_bytes; + { + base::ScopedAllowBlockingForTesting allow_blocking; + file_bytes = base::ReadFileToBytes(GetTestFilePath("dummy.txt")); + } + ASSERT_TRUE(file_bytes.has_value()); + + auto extractor = std::make_unique(); + base::test::TestFuture> future; + + extractor->ExtractText(browser_context(), std::move(*file_bytes), + FILE_PATH_LITERAL("txt"), future.GetCallback()); + + auto result = future.Take(); + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(*result, kExpectedTextContent); +} + +// HTML files must not be rendered — the extracted text should be the raw +// source containing HTML tags, not the rendered text content. +IN_PROC_BROWSER_TEST_F(TextFileExtractorBrowserTest, + PathOverload_HtmlNotRendered) { + base::FilePath html_path = GetTestFilePath("dummy.html"); + + auto extractor = std::make_unique(); + base::test::TestFuture> future; + + extractor->ExtractText(browser_context(), html_path, future.GetCallback()); + + auto result = future.Take(); + ASSERT_TRUE(result.has_value()); + // Raw source must contain HTML tags — proves it was NOT rendered. + EXPECT_NE(result->find("

Hello from an HTML file.

"), std::string::npos); + EXPECT_NE(result->find(" + + diff --git a/test/data/leo/dummy.txt b/test/data/leo/dummy.txt new file mode 100644 index 00000000000..9ba06051f86 --- /dev/null +++ b/test/data/leo/dummy.txt @@ -0,0 +1,2 @@ +Hello from a text file. +This is line two. \ No newline at end of file