feat(docs): optimize PDF via Ghostscript pdfwrite
Add oo docs optimize for native-text PDFs (OO-friendly rewrite without ocrmypdf overlay). Default ocr --force to false when text layer exists. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+44
-5
@@ -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())
|
||||||
@@ -45,10 +46,11 @@ func docsToolsCmd() *cobra.Command {
|
|||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
t := docpipe.LookPath()
|
t := docpipe.LookPath()
|
||||||
printObject(map[string]any{
|
printObject(map[string]any{
|
||||||
"pandoc": strOrNil(t.Pandoc),
|
"pandoc": strOrNil(t.Pandoc),
|
||||||
"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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 (
|
||||||
@@ -22,10 +23,11 @@ const DefaultMinTextChars = 200
|
|||||||
|
|
||||||
// Tools reports which converters are available on PATH.
|
// Tools reports which converters are available on PATH.
|
||||||
type Tools struct {
|
type Tools struct {
|
||||||
Pandoc string
|
Pandoc string
|
||||||
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).
|
||||||
@@ -39,10 +41,11 @@ func LookPath() Tools {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return Tools{
|
return Tools{
|
||||||
Pandoc: find("pandoc"),
|
Pandoc: find("pandoc"),
|
||||||
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 == "" {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user