Merge pull request #42 from eSlider/feat/docs-optimize-gs
feat(docs): Ghostscript PDF optimize for OO
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`,
|
||||
}
|
||||
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: <name>.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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user