Skip to content

Commit 07ffa5b

Browse files
committed
Extract abstracts from imported PDFs
1 parent c7ca6a2 commit 07ffa5b

6 files changed

Lines changed: 156 additions & 12 deletions

File tree

AGENTS.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
21
# Project Instructions
32

43
## Preferences
@@ -173,4 +172,3 @@ If the Xcode MCP is configured, prefer its tools over generic alternatives when
173172
- `XcodeListNavigatorIssues` — check for issues visible in the Xcode Issue Navigator
174173
- `ExecuteSnippet` — test a code snippet in the context of a source file
175174
- `XcodeRead`, `XcodeWrite`, `XcodeUpdate` — prefer these over generic file tools when working with Xcode project files
176-

Sources/Refman/InspectorView.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,12 @@ struct InspectorView: View {
123123
draft.methods = details.document.methods
124124
draft.limitations = details.document.limitations
125125
}
126+
// Refresh a newly extracted abstract without discarding an unsaved edit.
127+
.onChange(of: details.document.abstract) { previous, abstract in
128+
if draft.abstract == previous {
129+
draft.abstract = abstract
130+
}
131+
}
126132
}
127133

128134
/// A read-only section rendering one AI insight as Markdown, with a hint

Sources/RefmanCore/Import/ImportPipeline.swift

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,9 @@ public struct ImportPipeline: Sendable {
101101
if let existing = try repository.liveDocumentNeedingPDF(doi: doi, arxivId: arxivId) {
102102
var updated = existing
103103
record?.apply(to: &updated)
104+
if updated.abstract?.isEmpty != false {
105+
updated.abstract = extracted?.abstract
106+
}
104107
updated.fileHash = hash
105108
updated.fileName = url.lastPathComponent
106109
let details = try repository.update(
@@ -113,6 +116,7 @@ public struct ImportPipeline: Sendable {
113116

114117
var document = Document(
115118
title: extracted?.embeddedTitle ?? url.deletingPathExtension().lastPathComponent,
119+
abstract: extracted?.abstract,
116120
doi: doi,
117121
arxivId: arxivId,
118122
fileHash: hash,
@@ -157,7 +161,11 @@ public struct ImportPipeline: Sendable {
157161
let hash = try store.ingest(data: data)
158162
document.fileHash = hash
159163
document.fileName = name
160-
fullText = PDFTextExtractor.extract(from: store.url(forHash: hash))?.fullText
164+
let extracted = PDFTextExtractor.extract(from: store.url(forHash: hash))
165+
fullText = extracted?.fullText
166+
if document.abstract?.isEmpty != false {
167+
document.abstract = extracted?.abstract
168+
}
161169
}
162170

163171
let details = try repository.insert(
@@ -172,8 +180,11 @@ public struct ImportPipeline: Sendable {
172180
var updated = document
173181
updated.fileHash = hash
174182
updated.fileName = url.lastPathComponent
175-
let fullText = PDFTextExtractor.extract(from: store.url(forHash: hash))?.fullText
176-
return try repository.update(updated, fullText: fullText)
183+
let extracted = PDFTextExtractor.extract(from: store.url(forHash: hash))
184+
if updated.abstract?.isEmpty != false {
185+
updated.abstract = extracted?.abstract
186+
}
187+
return try repository.update(updated, fullText: extracted?.fullText)
177188
}
178189

179190
/// Downloads and attaches an open-access PDF to an existing document.
@@ -187,8 +198,11 @@ public struct ImportPipeline: Sendable {
187198
var updated = document
188199
updated.fileHash = hash
189200
updated.fileName = name
190-
let fullText = PDFTextExtractor.extract(from: store.url(forHash: hash))?.fullText
191-
return try repository.update(updated, fullText: fullText)
201+
let extracted = PDFTextExtractor.extract(from: store.url(forHash: hash))
202+
if updated.abstract?.isEmpty != false {
203+
updated.abstract = extracted?.abstract
204+
}
205+
return try repository.update(updated, fullText: extracted?.fullText)
192206
}
193207

194208
/// Finds a downloadable PDF, preferring the arXiv copy and falling back to
@@ -215,11 +229,13 @@ public struct ImportPipeline: Sendable {
215229
if record == nil, let arxivId = document.arxivId {
216230
record = try? await arXiv.resolve(arxivId: arxivId)
217231
}
218-
guard let record else { return nil }
219-
220232
var updated = document
221-
record.apply(to: &updated)
222-
let authors = record.authors.isEmpty ? nil : record.authorRecords
233+
record?.apply(to: &updated)
234+
if updated.abstract?.isEmpty != false, let hash = updated.fileHash {
235+
updated.abstract = PDFTextExtractor.extract(from: store.url(forHash: hash))?.abstract
236+
}
237+
guard record != nil || updated.abstract != document.abstract else { return nil }
238+
let authors = record.flatMap { $0.authors.isEmpty ? nil : $0.authorRecords }
223239
return try repository.update(updated, authors: authors)
224240
}
225241
}

Sources/RefmanCore/Metadata/PDFTextExtractor.swift

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ public enum PDFTextExtractor {
1010
public var headText: String
1111
/// Title from the PDF's document attributes, if plausible.
1212
public var embeddedTitle: String?
13+
/// Abstract found in the opening pages, if the PDF exposes usable text.
14+
public var abstract: String?
1315
}
1416

1517
public static func extract(from url: URL, headPages: Int = 2) -> Extracted? {
@@ -35,6 +37,67 @@ public enum PDFTextExtractor {
3537
}
3638

3739
return Extracted(
38-
pageCount: doc.pageCount, fullText: full, headText: head, embeddedTitle: title)
40+
pageCount: doc.pageCount,
41+
fullText: full,
42+
headText: head,
43+
embeddedTitle: title,
44+
abstract: abstract(in: head)
45+
)
46+
}
47+
48+
/// Extracts the text between an Abstract heading and the next front-matter
49+
/// or body heading. Line-based parsing matches PDFKit's output and
50+
/// avoids treating later mentions of "abstract" as section headings.
51+
static func abstract(in text: String) -> String? {
52+
var foundHeading = false
53+
var lines: [String] = []
54+
55+
for rawLine in text.components(separatedBy: .newlines) {
56+
let line = rawLine.trimmingCharacters(in: .whitespaces)
57+
58+
if !foundHeading {
59+
guard let remainder = abstractRemainder(in: line) else { continue }
60+
foundHeading = true
61+
if !remainder.isEmpty { lines.append(remainder) }
62+
continue
63+
}
64+
65+
if isAbstractBoundary(line) { break }
66+
lines.append(line)
67+
}
68+
69+
guard foundHeading else { return nil }
70+
let result = TextDecoding.cleanAbstract(lines.joined(separator: "\n"))
71+
guard !result.isEmpty, result.count <= 5_000 else { return nil }
72+
return result
73+
}
74+
75+
private static func abstractRemainder(in line: String) -> String? {
76+
guard let heading = line.range(
77+
of: #"(?i)^(?:a\s*b\s*s\s*t\s*r\s*a\s*c\s*t|s\s*u\s*m\s*m\s*a\s*r\s*y)(?=$|[\s:.|\-—–])"#,
78+
options: .regularExpression
79+
) else { return nil }
80+
81+
let remainder = line[heading.upperBound...]
82+
.trimmingCharacters(in: .whitespaces)
83+
guard let first = remainder.first else { return "" }
84+
85+
if ":.|-—–".contains(first) {
86+
return remainder.dropFirst()
87+
.trimmingCharacters(in: .whitespaces)
88+
}
89+
90+
// Without punctuation, accept inline text only after an all-caps or
91+
// letter-spaced heading. A title such as "Abstract algebra" is not one.
92+
let headingText = line[heading]
93+
guard headingText == headingText.uppercased() else { return nil }
94+
return remainder
95+
}
96+
97+
private static func isAbstractBoundary(_ line: String) -> Bool {
98+
line.range(
99+
of: #"(?i)^\s*(?:(?:(?:\d+(?:\.\d+)*|[ivx]+)[.)]?\s+)?(?:introduction(?:\s+and\s+background)?|main)\s*[.:]?\s*$|(?:keywords?|key\s+words?|index\s+terms?|ccs\s+concepts?|categories\s+and\s+subject\s+descriptors?|jel\s+classification)\b)"#,
100+
options: .regularExpression
101+
) != nil
39102
}
40103
}

Tests/RefmanCoreTests/ImportPipelineTests.swift

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ import Testing
7777
let doc = result.details.document
7878
#expect(doc.doi == "10.1234/example.5678")
7979
#expect(doc.arxivId == "2401.12345")
80+
#expect(doc.abstract == "We study things in depth using novel methodology.")
8081
#expect(doc.fileHash != nil)
8182
#expect(store.exists(hash: doc.fileHash!))
8283

@@ -124,4 +125,21 @@ import Testing
124125
#expect(counts.live == 1)
125126
#expect(counts.trashed == 0)
126127
}
128+
129+
@Test func refreshRecoversAbstractFromAttachedPDFWithoutOnlineMetadata() async throws {
130+
let (pipeline, repo, store) = try makePipeline()
131+
defer { try? FileManager.default.removeItem(at: store.rootURL) }
132+
133+
let pdf = try makePDF(
134+
text: "Abstract | This abstract was present only in the PDF.\n\nMain\nArticle body.")
135+
defer { try? FileManager.default.removeItem(at: pdf) }
136+
137+
let hash = try store.ingest(fileAt: pdf)
138+
let details = try repo.insert(
139+
Document(title: "Offline Paper", fileHash: hash, fileName: pdf.lastPathComponent))
140+
141+
let refreshed = try #require(
142+
try await pipeline.refreshMetadata(for: details.document))
143+
#expect(refreshed.document.abstract == "This abstract was present only in the PDF.")
144+
}
127145
}

Tests/RefmanCoreTests/MetadataTests.swift

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,49 @@ import Testing
33

44
@testable import RefmanCore
55

6+
@Suite struct PDFTextExtractorTests {
7+
@Test func extractsAbstractUntilIntroduction() {
8+
let text = """
9+
A Paper Title
10+
Jane Researcher
11+
12+
Abstract
13+
Background
14+
Existing extraction misses some layouts.
15+
Results
16+
This parser handles them.
17+
18+
1. Introduction
19+
This must not become part of the abstract.
20+
"""
21+
22+
#expect(
23+
PDFTextExtractor.abstract(in: text)
24+
== "Existing extraction misses some layouts. This parser handles them.")
25+
}
26+
27+
@Test func extractsInlineAndLetterSpacedHeadings() {
28+
#expect(
29+
PDFTextExtractor.abstract(
30+
in: "Abstract | We present a robust parser.\nKeywords: parsing, metadata")
31+
== "We present a robust parser.")
32+
#expect(
33+
PDFTextExtractor.abstract(in: "Abstract—We handle dash separators.\n1 Introduction")
34+
== "We handle dash separators.")
35+
#expect(
36+
PDFTextExtractor.abstract(
37+
in: "A B S T R A C T We support letter-spaced headings.\nI. INTRODUCTION")
38+
== "We support letter-spaced headings.")
39+
#expect(
40+
PDFTextExtractor.abstract(in: "Summary\nWe also support summary headings.\nMain")
41+
== "We also support summary headings.")
42+
}
43+
44+
@Test func ignoresTitleBeginningWithAbstract() {
45+
#expect(PDFTextExtractor.abstract(in: "Abstract Algebra for Beginners\nChapter One") == nil)
46+
}
47+
}
48+
649
@Suite struct IdentifierScannerTests {
750
@Test func findsDOIInProse() {
851
let text = "This article (doi: 10.1038/s41586-021-03819-2). More text follows."

0 commit comments

Comments
 (0)