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:
parent
6cb4026070
commit
a28370e4e3
4 changed files with 96 additions and 17 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue