feat: add reading order option to PDF OCR

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) <noreply@anthropic.com>
This commit is contained in:
dj-oyu 2026-03-20 10:24:35 +09:00
parent 6cb4026070
commit a28370e4e3
4 changed files with 96 additions and 17 deletions

View file

@ -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 {

View file

@ -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

View file

@ -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).

View file

@ -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).