Werkzeugkasten: PDF-Text aus Anhaengen und Dateien (#692)
- DocumentText reads PDF with PdfPig 0.1.16 (managed, Apache-2.0): page by page in reading order, at most 300 pages; a PDF without a text layer is named as probably scanned; broken and protected files are named. - Mail read_attachment and FileRW read/read_many take PDF like Word/Excel. - nuget.config maps exactly "PdfPig" - "UglyToad.PdfPig" is a package of a different account and stays out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
cd1d4501e4
commit
347b35fc44
@@ -28,6 +28,9 @@
|
||||
<package pattern="WTelegramClient" />
|
||||
<!-- WebFetch Stufe 1: HTML5-Parser mit CSS-Selektoren (#602) -->
|
||||
<package pattern="AngleSharp" />
|
||||
<!-- PDF-Text aus Anhaengen (#692). Genau dieser Name, ohne Platzhalter: unter
|
||||
"UglyToad.PdfPig" liegt ein Paket eines fremden Kontos. -->
|
||||
<package pattern="PdfPig" />
|
||||
<!-- Oberflaeche (Avalonia-Portierung) -->
|
||||
<package pattern="Avalonia" />
|
||||
<package pattern="Avalonia.*" />
|
||||
|
||||
@@ -806,7 +806,12 @@ beidem.
|
||||
> legt die Datei im eigenen Arbeitsbereich ab (nie über eine vorhandene, nie außerhalb).
|
||||
> FileRW `read`/`read_many` gibt Word- und Excel-Dateien als Text zurück; schreiben kann
|
||||
> es sie weiterhin nicht. Der Text ist Fremdinhalt und wird von der Engine gerahmt.
|
||||
> **Offen: PDF** — ein verlässlicher PDF-Textauszug ist eine Bibliotheksentscheidung.
|
||||
> **PDF** liest PdfPig (Paket `PdfPig`, Apache-2.0, nur verwalteter Code; Entscheidung des
|
||||
> Nutzers vom 2026-10-01 für eine lokale Bibliothek statt der Auslese über OpenRouter):
|
||||
> Seite für Seite in Leserichtung, höchstens 300 Seiten. Ein gescanntes PDF ohne Textebene
|
||||
> wird als solches benannt — OCR gibt es nicht. Achtung beim Paketnamen: unter
|
||||
> `UglyToad.PdfPig` liegt eine Vorabversion eines fremden Kontos; `nuget.config` lässt nur
|
||||
> genau `PdfPig` zu.
|
||||
3. **Der MCP-Client (#197) bleibt nützlich** — aber als *eine* Fähigkeit unter anderen,
|
||||
für Dienste, die ohnehin als Server bereitstehen. Nicht als Anbindungsstrategie.
|
||||
|
||||
|
||||
@@ -23,6 +23,11 @@
|
||||
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.8" />
|
||||
|
||||
<!-- #692: PDF text, managed code only, Apache-2.0. Das Paket heisst "PdfPig" (Eigentuemer
|
||||
EliotJones/BobLd, github.com/UglyToad/PdfPig) - NICHT "UglyToad.PdfPig": unter diesem
|
||||
Namen liegt eine fremde Vorabversion eines anderen Kontos. -->
|
||||
<PackageReference Include="PdfPig" Version="0.1.16" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -22,8 +22,8 @@ public sealed record DocumentExtraction(bool Ok, string Text, string Kind, bool
|
||||
/// <para><b>Bounded.</b> Office files are zip archives, and a zip can unpack to anything. Every
|
||||
/// part is read up to <see cref="MaxPartBytes"/>, the whole up to the caller's character limit.</para>
|
||||
///
|
||||
/// <para>PDF is not read here yet: a reliable PDF text extractor is a library decision, not a
|
||||
/// hundred lines of code — fonts with their own encodings come out as garbage otherwise.</para>
|
||||
/// <para><b>PDF</b> is read with PdfPig (managed code only): the text of each page in reading
|
||||
/// order. A scanned PDF has no text layer and is named as such — reading it would need OCR.</para>
|
||||
/// </summary>
|
||||
public static class DocumentText
|
||||
{
|
||||
@@ -40,7 +40,10 @@ public static class DocumentText
|
||||
|
||||
/// <summary>Whether the file is a document whose text needs extracting (not plain text).</summary>
|
||||
public static bool IsOfficeDocument(string fileName)
|
||||
=> Path.GetExtension(fileName).ToLowerInvariant() is ".docx" or ".xlsx";
|
||||
=> Path.GetExtension(fileName).ToLowerInvariant() is ".docx" or ".xlsx" or ".pdf";
|
||||
|
||||
/// <summary>Pages read from one PDF — an invoice has a few, a catalogue is not read whole.</summary>
|
||||
public const int MaxPdfPages = 300;
|
||||
|
||||
public static DocumentExtraction Extract(byte[] content, string fileName, int maxChars)
|
||||
{
|
||||
@@ -51,7 +54,7 @@ public static class DocumentText
|
||||
{
|
||||
".docx" => Bounded(Docx(content), "Word document", maxChars),
|
||||
".xlsx" => Bounded(Xlsx(content), "spreadsheet", maxChars),
|
||||
".pdf" => DocumentExtraction.Fail("PDF", "PDF text cannot be read in this version yet."),
|
||||
".pdf" => Pdf(content, maxChars),
|
||||
_ when PlainText.Contains(ext) => Bounded(Decode(content), "text", maxChars),
|
||||
_ => DocumentExtraction.Fail(ext.TrimStart('.'), $"'{ext}' files cannot be read as text.")
|
||||
};
|
||||
@@ -62,6 +65,44 @@ public static class DocumentText
|
||||
}
|
||||
}
|
||||
|
||||
// ─── PDF ───
|
||||
|
||||
private static DocumentExtraction Pdf(byte[] content, int maxChars)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var document = UglyToad.PdfPig.PdfDocument.Open(content, new UglyToad.PdfPig.ParsingOptions { UseLenientParsing = true });
|
||||
var sb = new StringBuilder();
|
||||
var pages = document.NumberOfPages;
|
||||
var read = 0;
|
||||
foreach (var page in document.GetPages())
|
||||
{
|
||||
if (read >= MaxPdfPages || sb.Length > maxChars)
|
||||
break;
|
||||
read++;
|
||||
var text = UglyToad.PdfPig.DocumentLayoutAnalysis.TextExtractor.ContentOrderTextExtractor.GetText(page).Trim();
|
||||
if (pages > 1)
|
||||
sb.AppendLine($"## Page {page.Number}");
|
||||
sb.AppendLine(text).AppendLine();
|
||||
}
|
||||
|
||||
var all = sb.ToString().Trim();
|
||||
if (all.Replace("## Page", "").All(c => char.IsWhiteSpace(c) || char.IsDigit(c)))
|
||||
return DocumentExtraction.Fail("PDF", "The PDF has no text layer — it is probably scanned, and reading it would need OCR.");
|
||||
if (read < pages)
|
||||
all += $"\n\n(Read {read} of {pages} pages.)";
|
||||
var bounded = Bounded(all, "PDF", maxChars);
|
||||
return bounded with { Truncated = bounded.Truncated || read < pages };
|
||||
}
|
||||
catch (Exception ex) when (ex is not OutOfMemoryException)
|
||||
{
|
||||
// PdfPig throws its own exceptions for broken and encrypted files.
|
||||
return DocumentExtraction.Fail("PDF", ex.Message.Contains("password", StringComparison.OrdinalIgnoreCase) || ex.Message.Contains("encrypt", StringComparison.OrdinalIgnoreCase)
|
||||
? "The PDF is protected by a password."
|
||||
: $"The PDF could not be read: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static DocumentExtraction Bounded(string text, string kind, int maxChars)
|
||||
{
|
||||
var limit = Math.Max(1, maxChars);
|
||||
|
||||
@@ -18,7 +18,7 @@ public sealed class MailTool : IAgentTool, IToolJobProvider
|
||||
/// <summary>#715: Brings text from outside into the context — mails from anyone.</summary>
|
||||
public bool ReadsForeignContent => true;
|
||||
|
||||
public string Description => "Allows sending and receiving emails via SMTP and IMAP. read_message lists a message's attachments; read_attachment returns an attachment's text (Word, Excel, text and CSV files); save_attachment stores it in your workspace.";
|
||||
public string Description => "Allows sending and receiving emails via SMTP and IMAP. read_message lists a message's attachments; read_attachment returns an attachment's text (PDF, Word, Excel, text and CSV files); save_attachment stores it in your workspace.";
|
||||
|
||||
public JsonElement InputSchema { get; } = JsonDocument.Parse("""
|
||||
{
|
||||
|
||||
@@ -109,10 +109,40 @@ public sealed class DocumentTextTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pdf_and_unknown_files_are_named_not_guessed()
|
||||
public void Broken_and_unknown_files_are_named_not_guessed()
|
||||
{
|
||||
DocumentText.Extract([1, 2, 3], "invoice.pdf", 100).Problem!.ShouldContain("PDF");
|
||||
DocumentText.Extract([1, 2, 3], "image.png", 100).Ok.ShouldBeFalse();
|
||||
DocumentText.Extract([1, 2, 3], "broken.docx", 100).Ok.ShouldBeFalse();
|
||||
}
|
||||
|
||||
private static byte[] Pdf(params string?[] pages)
|
||||
{
|
||||
var builder = new UglyToad.PdfPig.Writer.PdfDocumentBuilder();
|
||||
var font = builder.AddStandard14Font(UglyToad.PdfPig.Fonts.Standard14Fonts.Standard14Font.Helvetica);
|
||||
foreach (var text in pages)
|
||||
{
|
||||
var page = builder.AddPage(UglyToad.PdfPig.Content.PageSize.A4);
|
||||
if (text is not null)
|
||||
page.AddText(text, 12, new UglyToad.PdfPig.Core.PdfPoint(40, 700), font);
|
||||
}
|
||||
return builder.Build();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_pdf_comes_out_page_by_page()
|
||||
{
|
||||
var text = DocumentText.Extract(Pdf("Invoice 4711", "Total 150.00 EUR"), "invoice.pdf", 10_000);
|
||||
|
||||
text.Ok.ShouldBeTrue(text.Problem);
|
||||
text.Kind.ShouldBe("PDF");
|
||||
text.Text.ShouldContain("## Page 1");
|
||||
text.Text.ShouldContain("Invoice 4711");
|
||||
text.Text.ShouldContain("## Page 2");
|
||||
text.Text.ShouldContain("Total 150.00 EUR");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_pdf_without_text_is_named_as_probably_scanned()
|
||||
=> DocumentText.Extract(Pdf((string?)null), "scan.pdf", 10_000).Problem!.ShouldContain("no text layer");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user