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
|
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. " +
|
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
|
// processPDFsInMessages finds [file:/path.pdf] tags in messages and replaces
|
||||||
// them with [document: preview... (full: /path/to.md, N pages)] tags after
|
// them with [document: preview... (full: /path/to.md, N pages)] tags after
|
||||||
|
|
@ -456,8 +490,13 @@ func (al *AgentLoop) processPDFsInMessages(
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
withFigures := wantFigures(m.Content)
|
withFigures := wantFigures(m.Content)
|
||||||
|
var cfgRO string
|
||||||
|
if ocrCfg != nil {
|
||||||
|
cfgRO = ocrCfg.ReadingOrder
|
||||||
|
}
|
||||||
|
readingOrder := detectReadingOrder(m.Content, cfgRO)
|
||||||
result[i].Content = al.replacePDFTags(
|
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.
|
// replacePDFTags finds [file:*.pdf] tags and replaces them with OCR results.
|
||||||
func (al *AgentLoop) replacePDFTags(
|
func (al *AgentLoop) replacePDFTags(
|
||||||
ctx context.Context, content string, ocrCfg *config.OCRConfig,
|
ctx context.Context, content string, ocrCfg *config.OCRConfig,
|
||||||
channel, chatID string, withFigures bool,
|
channel, chatID string, withFigures bool, readingOrder string,
|
||||||
) string {
|
) string {
|
||||||
var out strings.Builder
|
var out strings.Builder
|
||||||
rest := content
|
rest := content
|
||||||
|
|
@ -494,7 +533,7 @@ func (al *AgentLoop) replacePDFTags(
|
||||||
out.WriteString(rest[:idx])
|
out.WriteString(rest[:idx])
|
||||||
|
|
||||||
if strings.HasSuffix(strings.ToLower(path), ".pdf") {
|
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 {
|
} else {
|
||||||
out.WriteString(tag)
|
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.
|
// ocrPDF runs OCR on a PDF file and returns a document tag with preview.
|
||||||
// Uses the media cache to avoid redundant OCR runs.
|
// Uses the media cache to avoid redundant OCR runs.
|
||||||
// When withFigures is true, --figure and --figure_letter flags are added.
|
// When withFigures is true, --figure and --figure_letter flags are added.
|
||||||
|
// readingOrder is passed as --reading_order to yomitoku.
|
||||||
func (al *AgentLoop) ocrPDF(
|
func (al *AgentLoop) ocrPDF(
|
||||||
ctx context.Context, pdfPath string, ocrCfg *config.OCRConfig,
|
ctx context.Context, pdfPath string, ocrCfg *config.OCRConfig,
|
||||||
channel, chatID string, withFigures bool,
|
channel, chatID string, withFigures bool, readingOrder string,
|
||||||
) string {
|
) string {
|
||||||
// Hash the file content for cache lookup.
|
// Hash the file content for cache lookup.
|
||||||
// Include figure mode in the hash so both variants are cached separately.
|
// Include figure mode in the hash so both variants are cached separately.
|
||||||
|
|
@ -523,6 +563,9 @@ func (al *AgentLoop) ocrPDF(
|
||||||
if withFigures {
|
if withFigures {
|
||||||
hashInput = append(hashInput, []byte(":figures")...)
|
hashInput = append(hashInput, []byte(":figures")...)
|
||||||
}
|
}
|
||||||
|
if readingOrder != "" && readingOrder != "auto" {
|
||||||
|
hashInput = append(hashInput, []byte(":ro="+readingOrder)...)
|
||||||
|
}
|
||||||
hash := mediacache.HashData(hashInput)
|
hash := mediacache.HashData(hashInput)
|
||||||
|
|
||||||
// Check cache (both text extraction and OCR)
|
// Check cache (both text extraction and OCR)
|
||||||
|
|
@ -564,8 +607,12 @@ func (al *AgentLoop) ocrPDF(
|
||||||
}
|
}
|
||||||
|
|
||||||
modeLabel := "Processing PDF"
|
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)"
|
modeLabel = "Processing PDF (with figures)"
|
||||||
|
} else if readingOrder != "" && readingOrder != "auto" {
|
||||||
|
modeLabel = fmt.Sprintf("Processing PDF (%s)", readingOrder)
|
||||||
}
|
}
|
||||||
indicator := al.processingIndicator(ctx, channel, chatID,
|
indicator := al.processingIndicator(ctx, channel, chatID,
|
||||||
fmt.Sprintf("%s (0/%s)...", modeLabel, totalStr))
|
fmt.Sprintf("%s (0/%s)...", modeLabel, totalStr))
|
||||||
|
|
@ -580,11 +627,14 @@ func (al *AgentLoop) ocrPDF(
|
||||||
cmdCtx, cmdCancel := context.WithTimeout(ctx, timeout)
|
cmdCtx, cmdCancel := context.WithTimeout(ctx, timeout)
|
||||||
defer cmdCancel()
|
defer cmdCancel()
|
||||||
|
|
||||||
args := make([]string, 0, len(ocrCfg.Args)+6)
|
args := make([]string, 0, len(ocrCfg.Args)+8)
|
||||||
args = append(args, ocrCfg.Args...)
|
args = append(args, ocrCfg.Args...)
|
||||||
if withFigures {
|
if withFigures {
|
||||||
args = append(args, "--figure", "--figure_letter")
|
args = append(args, "--figure", "--figure_letter")
|
||||||
}
|
}
|
||||||
|
if readingOrder != "" && readingOrder != "auto" {
|
||||||
|
args = append(args, "--reading_order", readingOrder)
|
||||||
|
}
|
||||||
args = append(args, pdfPath, "-o", outputDir)
|
args = append(args, pdfPath, "-o", outputDir)
|
||||||
|
|
||||||
cmd := exec.CommandContext(cmdCtx, ocrCfg.Command, args...)
|
cmd := exec.CommandContext(cmdCtx, ocrCfg.Command, args...)
|
||||||
|
|
@ -605,6 +655,7 @@ func (al *AgentLoop) ocrPDF(
|
||||||
"path": pdfPath,
|
"path": pdfPath,
|
||||||
"pages": totalStr,
|
"pages": totalStr,
|
||||||
"cmd": ocrCfg.Command,
|
"cmd": ocrCfg.Command,
|
||||||
|
"reading_order": readingOrder,
|
||||||
})
|
})
|
||||||
|
|
||||||
if startErr := cmd.Start(); startErr != nil {
|
if startErr := cmd.Start(); startErr != nil {
|
||||||
|
|
|
||||||
|
|
@ -151,7 +151,7 @@ func TestFormatDocumentTag_UnknownPages(t *testing.T) {
|
||||||
func TestReplacePDFTags_NoPDF(t *testing.T) {
|
func TestReplacePDFTags_NoPDF(t *testing.T) {
|
||||||
al := &AgentLoop{}
|
al := &AgentLoop{}
|
||||||
content := "Check this out [file:/path/to/audio.mp3]"
|
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 {
|
if result != content {
|
||||||
t.Errorf("non-PDF should be unchanged, got %q", result)
|
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) {
|
func TestReplacePDFTags_NoTags(t *testing.T) {
|
||||||
al := &AgentLoop{}
|
al := &AgentLoop{}
|
||||||
content := "Hello world"
|
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 {
|
if result != content {
|
||||||
t.Errorf("no tags should be unchanged, got %q", result)
|
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) {
|
func TestWantFigures(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
content string
|
content string
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ const pdfFollowUpWait = 5 * time.Second
|
||||||
|
|
||||||
// pdfFollowUpHint is sent when a PDF arrives without text.
|
// pdfFollowUpHint is sent when a PDF arrives without text.
|
||||||
const pdfFollowUpHint = "PDF received. You can send OCR options " +
|
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
|
// pdfCancelKeywords triggers OCR cancellation when found in a message
|
||||||
// received during Phase 2 (OCR in progress).
|
// received during Phase 2 (OCR in progress).
|
||||||
|
|
|
||||||
|
|
@ -251,6 +251,7 @@ type OCRConfig struct {
|
||||||
Args []string `json:"args,omitempty"`
|
Args []string `json:"args,omitempty"`
|
||||||
Env map[string]string `json:"env,omitempty"`
|
Env map[string]string `json:"env,omitempty"`
|
||||||
Timeout int `json:"timeout,omitempty"`
|
Timeout int `json:"timeout,omitempty"`
|
||||||
|
ReadingOrder string `json:"reading_order,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetOCRTimeout returns the configured timeout or default (600s = 10min).
|
// GetOCRTimeout returns the configured timeout or default (600s = 10min).
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue