From a28370e4e3830734e0488bdeecda903c48d7163d Mon Sep 17 00:00:00 2001 From: dj-oyu <68707227+dj-oyu@users.noreply.github.com> Date: Fri, 20 Mar 2026 10:24:35 +0900 Subject: [PATCH] feat: add reading order option to PDF OCR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detect reading order keywords (縦書き/横書き/vertical/horizontal etc.) from message content and pass --reading_order to yomitoku CLI. Falls back to config default, then omits the flag (yomitoku auto). - Add ReadingOrder field to OCRConfig - Add detectReadingOrder() with keyword matching - Thread readingOrder through processPDFsInMessages → ocrPDF - Include reading order in cache hash for separate variants - Update Phase 1/2 hint messages to mention 縦書き/横書き - Add TestDetectReadingOrder Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/agent/loop_media.go | 71 +++++++++++++++++++++++++++++++----- pkg/agent/loop_media_test.go | 31 +++++++++++++++- pkg/agent/loop_pdf_wait.go | 2 +- pkg/config/config.go | 9 +++-- 4 files changed, 96 insertions(+), 17 deletions(-) diff --git a/pkg/agent/loop_media.go b/pkg/agent/loop_media.go index 12750c2d7..6b7c7d7af 100644 --- a/pkg/agent/loop_media.go +++ b/pkg/agent/loop_media.go @@ -438,8 +438,42 @@ func wantFigures(content string) bool { return false } +// readingOrderKeywords maps message keywords to yomitoku --reading_order values. +// +//nolint:gosmopolitan // intentional CJK keywords for Japanese users +var readingOrderKeywords = []struct { + keyword string + order string +}{ + {"right2left", "right2left"}, + {"top2bottom", "top2bottom"}, + {"left2right", "left2right"}, + {"縦書き", "right2left"}, + {"たてがき", "right2left"}, + {"vertical", "right2left"}, + {"横書き", "top2bottom"}, + {"よこがき", "top2bottom"}, + {"horizontal", "top2bottom"}, +} + +// detectReadingOrder returns the reading order specified in the message content. +// Falls back to the configured default, then "auto". +func detectReadingOrder(content string, cfgDefault string) string { + lower := strings.ToLower(content) + for _, kw := range readingOrderKeywords { + if strings.Contains(lower, kw.keyword) { + return kw.order + } + } + if cfgDefault != "" { + return cfgDefault + } + return "auto" +} + const pdfHintMessage = "PDF OCR in progress. " + - "Tip: include \"figures\" or \"\u56f3\u7248\" in your message to extract images and in-figure text." + "Tip: include \"figures\" or \"\u56f3\u7248\" to extract images. " + + "Add \"\u7e26\u66f8\u304d\" or \"\u6a2a\u66f8\u304d\" to set reading order." // processPDFsInMessages finds [file:/path.pdf] tags in messages and replaces // them with [document: preview... (full: /path/to.md, N pages)] tags after @@ -456,8 +490,13 @@ func (al *AgentLoop) processPDFsInMessages( continue } withFigures := wantFigures(m.Content) + var cfgRO string + if ocrCfg != nil { + cfgRO = ocrCfg.ReadingOrder + } + readingOrder := detectReadingOrder(m.Content, cfgRO) result[i].Content = al.replacePDFTags( - ctx, m.Content, ocrCfg, channel, chatID, withFigures, + ctx, m.Content, ocrCfg, channel, chatID, withFigures, readingOrder, ) } @@ -470,7 +509,7 @@ const pdfTagPrefix = "[file:" // replacePDFTags finds [file:*.pdf] tags and replaces them with OCR results. func (al *AgentLoop) replacePDFTags( ctx context.Context, content string, ocrCfg *config.OCRConfig, - channel, chatID string, withFigures bool, + channel, chatID string, withFigures bool, readingOrder string, ) string { var out strings.Builder rest := content @@ -494,7 +533,7 @@ func (al *AgentLoop) replacePDFTags( out.WriteString(rest[:idx]) if strings.HasSuffix(strings.ToLower(path), ".pdf") { - out.WriteString(al.ocrPDF(ctx, path, ocrCfg, channel, chatID, withFigures)) + out.WriteString(al.ocrPDF(ctx, path, ocrCfg, channel, chatID, withFigures, readingOrder)) } else { out.WriteString(tag) } @@ -508,9 +547,10 @@ func (al *AgentLoop) replacePDFTags( // ocrPDF runs OCR on a PDF file and returns a document tag with preview. // Uses the media cache to avoid redundant OCR runs. // When withFigures is true, --figure and --figure_letter flags are added. +// readingOrder is passed as --reading_order to yomitoku. func (al *AgentLoop) ocrPDF( ctx context.Context, pdfPath string, ocrCfg *config.OCRConfig, - channel, chatID string, withFigures bool, + channel, chatID string, withFigures bool, readingOrder string, ) string { // Hash the file content for cache lookup. // Include figure mode in the hash so both variants are cached separately. @@ -523,6 +563,9 @@ func (al *AgentLoop) ocrPDF( if withFigures { hashInput = append(hashInput, []byte(":figures")...) } + if readingOrder != "" && readingOrder != "auto" { + hashInput = append(hashInput, []byte(":ro="+readingOrder)...) + } hash := mediacache.HashData(hashInput) // Check cache (both text extraction and OCR) @@ -564,8 +607,12 @@ func (al *AgentLoop) ocrPDF( } modeLabel := "Processing PDF" - if withFigures { + if withFigures && readingOrder != "" && readingOrder != "auto" { + modeLabel = fmt.Sprintf("Processing PDF (figures, %s)", readingOrder) + } else if withFigures { modeLabel = "Processing PDF (with figures)" + } else if readingOrder != "" && readingOrder != "auto" { + modeLabel = fmt.Sprintf("Processing PDF (%s)", readingOrder) } indicator := al.processingIndicator(ctx, channel, chatID, fmt.Sprintf("%s (0/%s)...", modeLabel, totalStr)) @@ -580,11 +627,14 @@ func (al *AgentLoop) ocrPDF( cmdCtx, cmdCancel := context.WithTimeout(ctx, timeout) defer cmdCancel() - args := make([]string, 0, len(ocrCfg.Args)+6) + args := make([]string, 0, len(ocrCfg.Args)+8) args = append(args, ocrCfg.Args...) if withFigures { args = append(args, "--figure", "--figure_letter") } + if readingOrder != "" && readingOrder != "auto" { + args = append(args, "--reading_order", readingOrder) + } args = append(args, pdfPath, "-o", outputDir) cmd := exec.CommandContext(cmdCtx, ocrCfg.Command, args...) @@ -602,9 +652,10 @@ func (al *AgentLoop) ocrPDF( } logger.InfoCF("agent", "Starting PDF OCR", map[string]any{ - "path": pdfPath, - "pages": totalStr, - "cmd": ocrCfg.Command, + "path": pdfPath, + "pages": totalStr, + "cmd": ocrCfg.Command, + "reading_order": readingOrder, }) if startErr := cmd.Start(); startErr != nil { diff --git a/pkg/agent/loop_media_test.go b/pkg/agent/loop_media_test.go index 4b2456659..58a168b71 100644 --- a/pkg/agent/loop_media_test.go +++ b/pkg/agent/loop_media_test.go @@ -151,7 +151,7 @@ func TestFormatDocumentTag_UnknownPages(t *testing.T) { func TestReplacePDFTags_NoPDF(t *testing.T) { al := &AgentLoop{} content := "Check this out [file:/path/to/audio.mp3]" - result := al.replacePDFTags(t.Context(), content, &config.OCRConfig{Command: "echo"}, "", "", false) + result := al.replacePDFTags(t.Context(), content, &config.OCRConfig{Command: "echo"}, "", "", false, "auto") if result != content { t.Errorf("non-PDF should be unchanged, got %q", result) } @@ -160,7 +160,7 @@ func TestReplacePDFTags_NoPDF(t *testing.T) { func TestReplacePDFTags_NoTags(t *testing.T) { al := &AgentLoop{} content := "Hello world" - result := al.replacePDFTags(t.Context(), content, &config.OCRConfig{Command: "echo"}, "", "", false) + result := al.replacePDFTags(t.Context(), content, &config.OCRConfig{Command: "echo"}, "", "", false, "auto") if result != content { t.Errorf("no tags should be unchanged, got %q", result) } @@ -178,6 +178,33 @@ func TestProcessPDFs_NilOCR(t *testing.T) { } } +func TestDetectReadingOrder(t *testing.T) { + tests := []struct { + content string + cfgDefault string + want string + }{ + {"この文書を読んで", "", "auto"}, + {"縦書きで読んで", "", "right2left"}, + {"横書きのPDF", "", "top2bottom"}, + {"vertical layout", "", "right2left"}, + {"horizontal doc", "", "top2bottom"}, + {"right2left please", "", "right2left"}, + {"top2bottom mode", "", "top2bottom"}, + {"left2right table", "", "left2right"}, + {"たてがきの文書", "", "right2left"}, + {"よこがきの文書", "", "top2bottom"}, + {"plain message", "right2left", "right2left"}, + {"plain message", "", "auto"}, + {"縦書き", "top2bottom", "right2left"}, // message keyword overrides config default + } + for _, tt := range tests { + if got := detectReadingOrder(tt.content, tt.cfgDefault); got != tt.want { + t.Errorf("detectReadingOrder(%q, %q) = %q, want %q", tt.content, tt.cfgDefault, got, tt.want) + } + } +} + func TestWantFigures(t *testing.T) { tests := []struct { content string diff --git a/pkg/agent/loop_pdf_wait.go b/pkg/agent/loop_pdf_wait.go index f4c4372db..6bc74a577 100644 --- a/pkg/agent/loop_pdf_wait.go +++ b/pkg/agent/loop_pdf_wait.go @@ -17,7 +17,7 @@ const pdfFollowUpWait = 5 * time.Second // pdfFollowUpHint is sent when a PDF arrives without text. const pdfFollowUpHint = "PDF received. You can send OCR options " + - "(e.g. \"figures\" / \"\u56f3\u7248\") within a few seconds, or processing will start automatically." + "(e.g. \"figures\" / \"\u56f3\u7248\", \"\u7e26\u66f8\u304d\" / \"\u6a2a\u66f8\u304d\") within a few seconds, or processing will start automatically." // pdfCancelKeywords triggers OCR cancellation when found in a message // received during Phase 2 (OCR in progress). diff --git a/pkg/config/config.go b/pkg/config/config.go index 98662a4ff..a20278218 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -247,10 +247,11 @@ type AgentDefaults struct { // OCRConfig configures the external OCR command for PDF text extraction. type OCRConfig struct { - Command string `json:"command"` - Args []string `json:"args,omitempty"` - Env map[string]string `json:"env,omitempty"` - Timeout int `json:"timeout,omitempty"` + Command string `json:"command"` + Args []string `json:"args,omitempty"` + Env map[string]string `json:"env,omitempty"` + Timeout int `json:"timeout,omitempty"` + ReadingOrder string `json:"reading_order,omitempty"` } // GetOCRTimeout returns the configured timeout or default (600s = 10min).