Merge pull request #42 from eSlider/feat/docs-optimize-gs
Release / GoReleaser (push) Skipped
Release Please / Release Please (push) Skipped
Tests / Secret scan (gitleaks) (push) Failing after 4s
Tests / Test (Go 1.25) (push) Successful in 20s
Tests / Test (Go stable) (push) Successful in 27s

feat(docs): Ghostscript PDF optimize for OO
This commit is contained in:
2026-08-28 10:03:04 +01:00
committed by GitHub
co-authored by GitHub
3 changed files with 115 additions and 13 deletions
+40 -1
View File
@@ -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`, Structured OCR (hOCR→MD): oo docs hocr scan.jpg --md out.md --yaml out.yml`,
} }
cmd.AddCommand(docsConvertCmd()) cmd.AddCommand(docsConvertCmd())
cmd.AddCommand(docsOptimizeCmd())
cmd.AddCommand(docsOCRCmd()) cmd.AddCommand(docsOCRCmd())
cmd.AddCommand(docsHOCRCmd()) cmd.AddCommand(docsHOCRCmd())
cmd.AddCommand(docsAsMDCmd()) cmd.AddCommand(docsAsMDCmd())
@@ -49,6 +50,7 @@ func docsToolsCmd() *cobra.Command {
"ocrmypdf": strOrNil(t.OCRMyPDF), "ocrmypdf": strOrNil(t.OCRMyPDF),
"pdftotext": strOrNil(t.PDFToText), "pdftotext": strOrNil(t.PDFToText),
"tesseract": strOrNil(t.Tesseract), "tesseract": strOrNil(t.Tesseract),
"ghostscript": strOrNil(t.Ghostscript),
}) })
return nil return nil
}, },
@@ -96,6 +98,43 @@ func docsConvertCmd() *cobra.Command {
return cmd 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: <name>.optimized.pdf)")
return cmd
}
func docsOCRCmd() *cobra.Command { func docsOCRCmd() *cobra.Command {
var out, mdOut, lang string var out, mdOut, lang string
var force bool var force bool
@@ -140,7 +179,7 @@ func docsOCRCmd() *cobra.Command {
cmd.Flags().StringVar(&mdOut, "md", "", "write Markdown extraction to this path") 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().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().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 return cmd
} }
+36
View File
@@ -6,6 +6,7 @@
// - ocrmypdf — OCR into a searchable PDF // - ocrmypdf — OCR into a searchable PDF
// - pdftotext — extract text layer // - pdftotext — extract text layer
// - tesseract — OCR single images when ocrmypdf is unsuitable // - tesseract — OCR single images when ocrmypdf is unsuitable
// - ghostscript (gs) — PDF rewrite/optimize via PostScript (pdfwrite)
package docpipe package docpipe
import ( import (
@@ -26,6 +27,7 @@ type Tools struct {
OCRMyPDF string OCRMyPDF string
PDFToText string PDFToText string
Tesseract string Tesseract string
Ghostscript string
} }
// LookPath resolves converter binaries (empty string if missing). // LookPath resolves converter binaries (empty string if missing).
@@ -43,6 +45,7 @@ func LookPath() Tools {
OCRMyPDF: find("ocrmypdf"), OCRMyPDF: find("ocrmypdf"),
PDFToText: find("pdftotext"), PDFToText: find("pdftotext"),
Tesseract: find("tesseract"), Tesseract: find("tesseract"),
Ghostscript: find("gs", "ghostscript"),
} }
} }
@@ -170,6 +173,39 @@ func (t Tools) DOCXToMD(docxPath, mdPath string) error {
return t.ConvertFile(docxPath, mdPath) 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). // PDFTextLayerChars returns approximate extracted character count (0 if unavailable).
func (t Tools) PDFTextLayerChars(pdfPath string) (int, error) { func (t Tools) PDFTextLayerChars(pdfPath string) (int, error) {
if t.PDFToText == "" { if t.PDFToText == "" {
+27
View File
@@ -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) { func TestToMarkdown_PlainMD(t *testing.T) {
tools := LookPath() tools := LookPath()
dir := t.TempDir() dir := t.TempDir()