diff --git a/cmd/oo/docs.go b/cmd/oo/docs.go index f78ca39..4bbfa37 100644 --- a/cmd/oo/docs.go +++ b/cmd/oo/docs.go @@ -29,6 +29,7 @@ OCR a scan locally: oo docs ocr scan.pdf --md out.md Structured OCR (hOCR→MD): oo docs hocr scan.jpg --md out.md --yaml out.yml`, } cmd.AddCommand(docsConvertCmd()) + cmd.AddCommand(docsOptimizeCmd()) cmd.AddCommand(docsOCRCmd()) cmd.AddCommand(docsHOCRCmd()) cmd.AddCommand(docsAsMDCmd()) @@ -45,10 +46,11 @@ func docsToolsCmd() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { t := docpipe.LookPath() printObject(map[string]any{ - "pandoc": strOrNil(t.Pandoc), - "ocrmypdf": strOrNil(t.OCRMyPDF), - "pdftotext": strOrNil(t.PDFToText), - "tesseract": strOrNil(t.Tesseract), + "pandoc": strOrNil(t.Pandoc), + "ocrmypdf": strOrNil(t.OCRMyPDF), + "pdftotext": strOrNil(t.PDFToText), + "tesseract": strOrNil(t.Tesseract), + "ghostscript": strOrNil(t.Ghostscript), }) return nil }, @@ -96,6 +98,43 @@ func docsConvertCmd() *cobra.Command { return cmd } +func docsOptimizeCmd() *cobra.Command { + var out string + cmd := &cobra.Command{ + Use: "optimize PDF_PATH", + Short: "Rewrite PDF via Ghostscript (PostScript pdfwrite) for OO-friendly size/text", + Long: `Use for InDesign/iText PDFs with a good text layer — avoids ocrmypdf invisible +text overlays that break OnlyOffice DocEditor. Skips OCR; rewrites via gs pdfwrite.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + in := args[0] + t := docpipe.LookPath() + if out == "" { + base := strings.TrimSuffix(filepath.Base(in), filepath.Ext(in)) + out = filepath.Join(filepath.Dir(in), base+".optimized.pdf") + } + if err := docpipe.EnsureDir(out); err != nil { + return err + } + chars, _ := t.PDFTextLayerChars(in) + if err := t.OptimizePDF(in, out); err != nil { + return err + } + outChars, _ := t.PDFTextLayerChars(out) + printObject(map[string]any{ + "in": in, + "pdf": out, + "text_chars_in": chars, + "text_chars_out": outChars, + "note": "native text layer preserved; no OCR overlay", + }) + return nil + }, + } + cmd.Flags().StringVar(&out, "out", "", "output PDF (default: .optimized.pdf)") + return cmd +} + func docsOCRCmd() *cobra.Command { var out, mdOut, lang string var force bool @@ -140,7 +179,7 @@ func docsOCRCmd() *cobra.Command { cmd.Flags().StringVar(&mdOut, "md", "", "write Markdown extraction to this path") cmd.Flags().BoolVar(&writeMD, "markdown", false, "also write sibling .md next to OCR PDF") cmd.Flags().StringVar(&lang, "lang", "eng", "OCR language(s) for tesseract/ocrmypdf") - cmd.Flags().BoolVar(&force, "force", true, "force OCR even if a text layer exists") + cmd.Flags().BoolVar(&force, "force", false, "force OCR even if a text layer exists (default: skip when pdftotext finds enough text)") return cmd } diff --git a/internal/docpipe/docpipe.go b/internal/docpipe/docpipe.go index 44b7aa3..c5038d6 100644 --- a/internal/docpipe/docpipe.go +++ b/internal/docpipe/docpipe.go @@ -6,6 +6,7 @@ // - ocrmypdf — OCR into a searchable PDF // - pdftotext — extract text layer // - tesseract — OCR single images when ocrmypdf is unsuitable +// - ghostscript (gs) — PDF rewrite/optimize via PostScript (pdfwrite) package docpipe import ( @@ -22,10 +23,11 @@ const DefaultMinTextChars = 200 // Tools reports which converters are available on PATH. type Tools struct { - Pandoc string - OCRMyPDF string - PDFToText string - Tesseract string + Pandoc string + OCRMyPDF string + PDFToText string + Tesseract string + Ghostscript string } // LookPath resolves converter binaries (empty string if missing). @@ -39,10 +41,11 @@ func LookPath() Tools { return "" } return Tools{ - Pandoc: find("pandoc"), - OCRMyPDF: find("ocrmypdf"), - PDFToText: find("pdftotext"), - Tesseract: find("tesseract"), + Pandoc: find("pandoc"), + OCRMyPDF: find("ocrmypdf"), + PDFToText: find("pdftotext"), + Tesseract: find("tesseract"), + Ghostscript: find("gs", "ghostscript"), } } @@ -170,6 +173,39 @@ func (t Tools) DOCXToMD(docxPath, mdPath string) error { return t.ConvertFile(docxPath, mdPath) } +// OptimizePDF rewrites a PDF through Ghostscript (PostScript pdfwrite). +// Preserves native text layers; strips broken OCR overlays; shrinks for OO preview. +// Use instead of ocrmypdf when pdftotext already extracts enough text. +func (t Tools) OptimizePDF(inPath, outPath string) error { + if t.Ghostscript == "" { + return fmt.Errorf("ghostscript (gs) not found on PATH") + } + if outPath == "" { + return fmt.Errorf("output PDF path required") + } + args := []string{ + "-sDEVICE=pdfwrite", + "-dCompatibilityLevel=1.5", + "-dNOPAUSE", "-dQUIET", "-dBATCH", + "-dPDFSETTINGS=/ebook", + "-dEmbedAllFonts=true", + "-dSubsetFonts=true", + "-dCompressFonts=true", + "-dCompressPages=true", + "-dDetectDuplicateImages=true", + "-dAutoRotatePages=/None", + "-sOutputFile=" + outPath, + inPath, + } + cmd := exec.Command(t.Ghostscript, args...) + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("ghostscript pdfwrite: %w (%s)", err, strings.TrimSpace(stderr.String())) + } + return nil +} + // PDFTextLayerChars returns approximate extracted character count (0 if unavailable). func (t Tools) PDFTextLayerChars(pdfPath string) (int, error) { if t.PDFToText == "" { diff --git a/internal/docpipe/docpipe_test.go b/internal/docpipe/docpipe_test.go index f4d25a9..93e1fc4 100644 --- a/internal/docpipe/docpipe_test.go +++ b/internal/docpipe/docpipe_test.go @@ -128,6 +128,33 @@ func TestMDDocxRoundTrip(t *testing.T) { } } +func TestOptimizePDF(t *testing.T) { + tools := LookPath() + if tools.Ghostscript == "" || tools.PDFToText == "" { + t.Skip("ghostscript/pdftotext not installed") + } + in := "/tmp/ccgg-original.pdf" + if _, err := os.Stat(in); err != nil { + t.Skip("local fixture not present") + } + dir := t.TempDir() + out := filepath.Join(dir, "out.pdf") + charsIn, err := tools.PDFTextLayerChars(in) + if err != nil || charsIn < 1000 { + t.Skip("fixture has no text layer") + } + if err := tools.OptimizePDF(in, out); err != nil { + t.Fatal(err) + } + charsOut, err := tools.PDFTextLayerChars(out) + if err != nil { + t.Fatal(err) + } + if charsOut < charsIn/2 { + t.Fatalf("text layer lost: in=%d out=%d", charsIn, charsOut) + } +} + func TestToMarkdown_PlainMD(t *testing.T) { tools := LookPath() dir := t.TempDir()