Skip to content

Commit 962b83a

Browse files
committed
feat: implement vector PDF generation with selectable text for iOS and Android
- iOS: UIPrintPageRenderer with viewPrintFormatter for native PDF rendering - Android: WebView.createPrintDocumentAdapter for vector output - Support for custom headers, footers, and page numbers - Multi-page documents with automatic pagination - Configurable page sizes (A4, Letter, Legal, A3, A5) - Customizable margins and styling - Selectable text in generated PDFs on both platforms
1 parent cebde0b commit 962b83a

14 files changed

Lines changed: 16192 additions & 24 deletions

File tree

android/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,4 +115,5 @@ android {
115115
dependencies {
116116
implementation "com.facebook.react:react-android"
117117
implementation project(":react-native-nitro-modules")
118+
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3"
118119
}
Lines changed: 211 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,215 @@
11
package com.margelo.nitro.nitrohtmlpdf
2-
3-
import com.facebook.proguard.annotations.DoNotStrip
42

5-
@DoNotStrip
3+
import android.content.Context
4+
import android.graphics.Paint
5+
import android.graphics.pdf.PdfDocument
6+
import android.os.Handler
7+
import android.os.Looper
8+
import android.webkit.WebView
9+
import android.webkit.WebViewClient
10+
import com.margelo.nitro.core.Promise
11+
import java.io.File
12+
import java.io.FileOutputStream
13+
import java.util.concurrent.CountDownLatch
14+
import java.util.concurrent.TimeUnit
15+
import kotlin.math.ceil
16+
617
class NitroHtmlPdf : HybridNitroHtmlPdfSpec() {
7-
override fun multiply(a: Double, b: Double): Double {
8-
return a * b
9-
}
18+
companion object {
19+
@JvmStatic
20+
var appContext: Context? = null
21+
}
22+
23+
private val context: Context
24+
get() = appContext ?: throw IllegalStateException("Context not initialized. Call NitroHtmlPdf.appContext = context first.")
25+
26+
override fun generatePdf(options: PdfOptions): Promise<PdfResult> {
27+
return Promise.async {
28+
createPdf(options)
29+
}
30+
}
31+
32+
private fun createPdf(options: PdfOptions): PdfResult {
33+
val latch = CountDownLatch(1)
34+
var result: PdfResult? = null
35+
36+
Handler(Looper.getMainLooper()).post {
37+
try {
38+
val pageSizeString = options.pageSize?.name ?: "A4"
39+
val pageSize = getPageSize(pageSizeString, options.width, options.height)
40+
val headerHeight = options.headerHeight?.toFloat() ?: 0f
41+
val footerHeight = options.footerHeight?.toFloat() ?: 0f
42+
43+
var headerWebView: WebView? = null
44+
var footerWebView: WebView? = null
45+
val webViews = mutableListOf<WebView>()
46+
47+
if (!options.header.isNullOrEmpty() && headerHeight > 0) {
48+
headerWebView = createWebView(pageSize.width.toInt(), headerHeight.toInt())
49+
val wrappedHeader = "<!DOCTYPE html><html><head><meta name='viewport' content='width=device-width,initial-scale=1,maximum-scale=1'><style>*{margin:0!important;padding:0!important;box-sizing:border-box;}html,body{margin:0!important;padding:0!important;width:100%;height:100%;overflow:hidden;}</style></head><body>${options.header}</body></html>"
50+
headerWebView.loadDataWithBaseURL(null, wrappedHeader, "text/html", "UTF-8", null)
51+
webViews.add(headerWebView)
52+
}
53+
54+
if (!options.footer.isNullOrEmpty() && footerHeight > 0) {
55+
footerWebView = createWebView(pageSize.width.toInt(), footerHeight.toInt())
56+
val wrappedFooter = "<!DOCTYPE html><html><head><meta name='viewport' content='width=device-width,initial-scale=1,maximum-scale=1'><style>*{margin:0!important;padding:0!important;box-sizing:border-box;}html,body{margin:0!important;padding:0!important;width:100%;height:100%;overflow:hidden;}</style></head><body>${options.footer}</body></html>"
57+
footerWebView.loadDataWithBaseURL(null, wrappedFooter, "text/html", "UTF-8", null)
58+
webViews.add(footerWebView)
59+
}
60+
61+
val contentWebView = createWebView(pageSize.width.toInt(), pageSize.height.toInt())
62+
contentWebView.loadDataWithBaseURL(null, options.html, "text/html", "UTF-8", null)
63+
webViews.add(contentWebView)
64+
65+
val loadLatch = CountDownLatch(webViews.size)
66+
webViews.forEach { webView ->
67+
webView.webViewClient = object : WebViewClient() {
68+
override fun onPageFinished(view: WebView?, url: String?) {
69+
loadLatch.countDown()
70+
}
71+
}
72+
}
73+
74+
Thread {
75+
loadLatch.await(5, TimeUnit.SECONDS)
76+
Thread.sleep(500)
77+
Handler(Looper.getMainLooper()).post {
78+
result = renderPdf(contentWebView, headerWebView, footerWebView, options)
79+
webViews.forEach { it.destroy() }
80+
latch.countDown()
81+
}
82+
}.start()
83+
84+
} catch (e: Exception) {
85+
result = PdfResult("", false, e.message)
86+
latch.countDown()
87+
}
88+
}
89+
90+
latch.await()
91+
return result ?: PdfResult("", false, "Failed to create PDF")
92+
}
93+
94+
private fun createWebView(width: Int, height: Int): WebView {
95+
return WebView(context).apply {
96+
settings.javaScriptEnabled = true
97+
settings.loadWithOverviewMode = false
98+
settings.useWideViewPort = false
99+
settings.domStorageEnabled = true
100+
settings.setSupportZoom(false)
101+
setInitialScale(100)
102+
measure(
103+
android.view.View.MeasureSpec.makeMeasureSpec(width, android.view.View.MeasureSpec.EXACTLY),
104+
android.view.View.MeasureSpec.makeMeasureSpec(height, android.view.View.MeasureSpec.EXACTLY)
105+
)
106+
layout(0, 0, width, height)
107+
}
108+
}
109+
110+
private fun renderPdf(
111+
webView: WebView,
112+
headerWebView: WebView?,
113+
footerWebView: WebView?,
114+
options: PdfOptions
115+
): PdfResult {
116+
return try {
117+
val pageSizeString = options.pageSize?.name ?: "A4"
118+
val pageSize = getPageSize(pageSizeString, options.width, options.height)
119+
val headerHeight = options.headerHeight?.toFloat() ?: 0f
120+
val footerHeight = options.footerHeight?.toFloat() ?: 0f
121+
val marginTop = (options.marginTop?.toFloat() ?: 0f) + headerHeight
122+
val marginBottom = (options.marginBottom?.toFloat() ?: 0f) + footerHeight + if (options.showPageNumbers == true) 20f else 0f
123+
val marginLeft = options.marginLeft?.toFloat() ?: 0f
124+
val marginRight = options.marginRight?.toFloat() ?: 0f
125+
126+
webView.measure(
127+
android.view.View.MeasureSpec.makeMeasureSpec(pageSize.width.toInt(), android.view.View.MeasureSpec.EXACTLY),
128+
android.view.View.MeasureSpec.makeMeasureSpec(0, android.view.View.MeasureSpec.UNSPECIFIED)
129+
)
130+
webView.layout(0, 0, pageSize.width.toInt(), webView.measuredHeight)
131+
132+
val contentHeight = webView.measuredHeight.toFloat()
133+
val printableHeight = pageSize.height - marginTop - marginBottom
134+
val totalPages = ceil(contentHeight / printableHeight).toInt().coerceAtLeast(1)
135+
136+
val pdfDocument = PdfDocument()
137+
138+
for (pageIndex in 0 until totalPages) {
139+
val pageInfo = PdfDocument.PageInfo.Builder(
140+
pageSize.width.toInt(),
141+
pageSize.height.toInt(),
142+
pageIndex
143+
).create()
144+
145+
val page = pdfDocument.startPage(pageInfo)
146+
val canvas = page.canvas
147+
148+
canvas.save()
149+
canvas.translate(marginLeft, marginTop)
150+
canvas.clipRect(0f, 0f, pageSize.width - marginLeft - marginRight, printableHeight)
151+
canvas.translate(0f, -(pageIndex * printableHeight))
152+
webView.draw(canvas)
153+
canvas.restore()
154+
155+
if (headerWebView != null && headerHeight > 0) {
156+
canvas.save()
157+
canvas.translate(marginLeft, 0f)
158+
headerWebView.draw(canvas)
159+
canvas.restore()
160+
}
161+
162+
if (options.showPageNumbers == true) {
163+
val currentPage = pageIndex + 1
164+
var pageText = options.pageNumberFormat ?: "Page {page} of {total}"
165+
pageText = pageText.replace("{page}", currentPage.toString())
166+
pageText = pageText.replace("{total}", totalPages.toString())
167+
168+
val paint = Paint().apply {
169+
textSize = options.pageNumberFontSize?.toFloat() ?: 12f
170+
color = android.graphics.Color.BLACK
171+
textAlign = Paint.Align.CENTER
172+
}
173+
val x = pageSize.width / 2
174+
val y = pageSize.height - footerHeight - 5f
175+
canvas.drawText(pageText, x, y, paint)
176+
}
177+
178+
if (footerWebView != null && footerHeight > 0) {
179+
canvas.save()
180+
canvas.translate(marginLeft, pageSize.height - footerHeight)
181+
footerWebView.draw(canvas)
182+
canvas.restore()
183+
}
184+
185+
pdfDocument.finishPage(page)
186+
}
187+
188+
val directory = options.directory ?: context.cacheDir?.absolutePath ?: "/data/local/tmp"
189+
val file = File(directory, options.fileName)
190+
FileOutputStream(file).use { pdfDocument.writeTo(it) }
191+
pdfDocument.close()
192+
193+
PdfResult(file.absolutePath, true, null)
194+
} catch (e: Exception) {
195+
PdfResult("", false, e.message)
196+
}
197+
}
198+
199+
private fun getPageSize(size: String, width: Double?, height: Double?): Size {
200+
if (width != null && height != null) {
201+
return Size(width.toFloat(), height.toFloat())
202+
}
203+
204+
return when (size) {
205+
"A4" -> Size(595f, 842f)
206+
"LETTER" -> Size(612f, 792f)
207+
"LEGAL" -> Size(612f, 1008f)
208+
"A3" -> Size(842f, 1191f)
209+
"A5" -> Size(420f, 595f)
210+
else -> Size(595f, 842f)
211+
}
212+
}
213+
214+
private data class Size(val width: Float, val height: Float)
10215
}

example/Gemfile.lock

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
GEM
2+
remote: https://rubygems.org/
3+
specs:
4+
CFPropertyList (3.0.8)
5+
activesupport (7.2.3)
6+
base64
7+
benchmark (>= 0.3)
8+
bigdecimal
9+
concurrent-ruby (~> 1.0, >= 1.3.1)
10+
connection_pool (>= 2.2.5)
11+
drb
12+
i18n (>= 1.6, < 2)
13+
logger (>= 1.4.2)
14+
minitest (>= 5.1)
15+
securerandom (>= 0.3)
16+
tzinfo (~> 2.0, >= 2.0.5)
17+
addressable (2.8.8)
18+
public_suffix (>= 2.0.2, < 8.0)
19+
algoliasearch (1.27.5)
20+
httpclient (~> 2.8, >= 2.8.3)
21+
json (>= 1.5.1)
22+
atomos (0.1.3)
23+
base64 (0.3.0)
24+
benchmark (0.5.0)
25+
bigdecimal (4.0.1)
26+
claide (1.1.0)
27+
cocoapods (1.15.2)
28+
addressable (~> 2.8)
29+
claide (>= 1.0.2, < 2.0)
30+
cocoapods-core (= 1.15.2)
31+
cocoapods-deintegrate (>= 1.0.3, < 2.0)
32+
cocoapods-downloader (>= 2.1, < 3.0)
33+
cocoapods-plugins (>= 1.0.0, < 2.0)
34+
cocoapods-search (>= 1.0.0, < 2.0)
35+
cocoapods-trunk (>= 1.6.0, < 2.0)
36+
cocoapods-try (>= 1.1.0, < 2.0)
37+
colored2 (~> 3.1)
38+
escape (~> 0.0.4)
39+
fourflusher (>= 2.3.0, < 3.0)
40+
gh_inspector (~> 1.0)
41+
molinillo (~> 0.8.0)
42+
nap (~> 1.0)
43+
ruby-macho (>= 2.3.0, < 3.0)
44+
xcodeproj (>= 1.23.0, < 2.0)
45+
cocoapods-core (1.15.2)
46+
activesupport (>= 5.0, < 8)
47+
addressable (~> 2.8)
48+
algoliasearch (~> 1.0)
49+
concurrent-ruby (~> 1.1)
50+
fuzzy_match (~> 2.0.4)
51+
nap (~> 1.0)
52+
netrc (~> 0.11)
53+
public_suffix (~> 4.0)
54+
typhoeus (~> 1.0)
55+
cocoapods-deintegrate (1.0.5)
56+
cocoapods-downloader (2.1)
57+
cocoapods-plugins (1.0.0)
58+
nap
59+
cocoapods-search (1.0.1)
60+
cocoapods-trunk (1.6.0)
61+
nap (>= 0.8, < 2.0)
62+
netrc (~> 0.11)
63+
cocoapods-try (1.2.0)
64+
colored2 (3.1.2)
65+
concurrent-ruby (1.3.3)
66+
connection_pool (3.0.2)
67+
drb (2.2.3)
68+
escape (0.0.4)
69+
ethon (0.15.0)
70+
ffi (>= 1.15.0)
71+
ffi (1.17.3)
72+
fourflusher (2.3.1)
73+
fuzzy_match (2.0.4)
74+
gh_inspector (1.1.3)
75+
httpclient (2.9.0)
76+
mutex_m
77+
i18n (1.14.8)
78+
concurrent-ruby (~> 1.0)
79+
json (2.18.1)
80+
logger (1.7.0)
81+
minitest (6.0.1)
82+
prism (~> 1.5)
83+
molinillo (0.8.0)
84+
mutex_m (0.3.0)
85+
nanaimo (0.3.0)
86+
nap (1.1.0)
87+
netrc (0.11.0)
88+
prism (1.9.0)
89+
public_suffix (4.0.7)
90+
rexml (3.4.4)
91+
ruby-macho (2.5.1)
92+
securerandom (0.4.1)
93+
typhoeus (1.5.0)
94+
ethon (>= 0.9.0, < 0.16.0)
95+
tzinfo (2.0.6)
96+
concurrent-ruby (~> 1.0)
97+
xcodeproj (1.25.1)
98+
CFPropertyList (>= 2.3.3, < 4.0)
99+
atomos (~> 0.1.3)
100+
claide (>= 1.0.2, < 2.0)
101+
colored2 (~> 3.1)
102+
nanaimo (~> 0.3.0)
103+
rexml (>= 3.3.6, < 4.0)
104+
105+
PLATFORMS
106+
ruby
107+
108+
DEPENDENCIES
109+
activesupport (>= 6.1.7.5, != 7.1.0)
110+
benchmark
111+
bigdecimal
112+
cocoapods (>= 1.13, != 1.15.1, != 1.15.0)
113+
concurrent-ruby (< 1.3.4)
114+
logger
115+
mutex_m
116+
xcodeproj (< 1.26.0)
117+
118+
CHECKSUMS
119+
CFPropertyList (3.0.8) sha256=2c99d0d980536d3d7ab252f7bd59ac8be50fbdd1ff487c98c949bb66bb114261
120+
activesupport (7.2.3) sha256=5675c9770dac93e371412684249f9dc3c8cec104efd0624362a520ae685c7b10
121+
addressable (2.8.8) sha256=7c13b8f9536cf6364c03b9d417c19986019e28f7c00ac8132da4eb0fe393b057
122+
algoliasearch (1.27.5) sha256=26c1cddf3c2ec4bd60c148389e42702c98fdac862881dc6b07a4c0b89ffec853
123+
atomos (0.1.3) sha256=7d43b22f2454a36bace5532d30785b06de3711399cb1c6bf932573eda536789f
124+
base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
125+
benchmark (0.5.0) sha256=465df122341aedcb81a2a24b4d3bd19b6c67c1530713fd533f3ff034e419236c
126+
bigdecimal (4.0.1) sha256=8b07d3d065a9f921c80ceaea7c9d4ae596697295b584c296fe599dd0ad01c4a7
127+
claide (1.1.0) sha256=6d3c5c089dde904d96aa30e73306d0d4bd444b1accb9b3125ce14a3c0183f82e
128+
cocoapods (1.15.2) sha256=f0f5153de8d028d133b96f423e04f37fb97a1da0d11dda581a9f46c0cba4090a
129+
cocoapods-core (1.15.2) sha256=322650d97fe1ad4c0831a09669764b888bd91c6d79d0f6bb07281a17667a2136
130+
cocoapods-deintegrate (1.0.5) sha256=517c2a448ef563afe99b6e7668704c27f5de9e02715a88ee9de6974dc1b3f6a2
131+
cocoapods-downloader (2.1) sha256=bb6ebe1b3966dc4055de54f7a28b773485ac724fdf575d9bee2212d235e7b6d1
132+
cocoapods-plugins (1.0.0) sha256=725d17ce90b52f862e73476623fd91441b4430b742d8a071000831efb440ca9a
133+
cocoapods-search (1.0.1) sha256=1b133b0e6719ed439bd840e84a1828cca46425ab73a11eff5e096c3b2df05589
134+
cocoapods-trunk (1.6.0) sha256=5f5bda8c172afead48fa2d43a718cf534b1313c367ba1194cebdeb9bfee9ed31
135+
cocoapods-try (1.2.0) sha256=145b946c6e7747ed0301d975165157951153d27469e6b2763c83e25c84b9defe
136+
colored2 (3.1.2) sha256=b13c2bd7eeae2cf7356a62501d398e72fde78780bd26aec6a979578293c28b4a
137+
concurrent-ruby (1.3.3) sha256=4f9cd28965c4dcf83ffd3ea7304f9323277be8525819cb18a3b61edcb56a7c6a
138+
connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a
139+
drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373
140+
escape (0.0.4) sha256=e49f44ae2b4f47c6a3abd544ae77fe4157802794e32f19b8e773cbc4dcec4169
141+
ethon (0.15.0) sha256=0809805a035bc10f54162ca99f15ded49e428e0488bcfe1c08c821e18261a74d
142+
ffi (1.17.3) sha256=0e9f39f7bb3934f77ad6feab49662be77e87eedcdeb2a3f5c0234c2938563d4c
143+
fourflusher (2.3.1) sha256=1b3de61c7c791b6a4e64f31e3719eb25203d151746bb519a0292bff1065ccaa9
144+
fuzzy_match (2.0.4) sha256=b5de4f95816589c5b5c3ad13770c0af539b75131c158135b3f3bbba75d0cfca5
145+
gh_inspector (1.1.3) sha256=04cca7171b87164e053aa43147971d3b7f500fcb58177698886b48a9fc4a1939
146+
httpclient (2.9.0) sha256=4b645958e494b2f86c2f8a2f304c959baa273a310e77a2931ddb986d83e498c8
147+
i18n (1.14.8) sha256=285778639134865c5e0f6269e0b818256017e8cde89993fdfcbfb64d088824a5
148+
json (2.18.1) sha256=fe112755501b8d0466b5ada6cf50c8c3f41e897fa128ac5d263ec09eedc9f986
149+
logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
150+
minitest (6.0.1) sha256=7854c74f48e2e975969062833adc4013f249a4b212f5e7b9d5c040bf838d54bb
151+
molinillo (0.8.0) sha256=efbff2716324e2a30bccd3eba1ff3a735f4d5d53ffddbc6a2f32c0ca9433045d
152+
mutex_m (0.3.0) sha256=cfcb04ac16b69c4813777022fdceda24e9f798e48092a2b817eb4c0a782b0751
153+
nanaimo (0.3.0) sha256=aaaedc60497070b864a7e220f7c4b4cad3a0daddda2c30055ba8dae306342376
154+
nap (1.1.0) sha256=949691660f9d041d75be611bb2a8d2fd559c467537deac241f4097d9b5eea576
155+
netrc (0.11.0) sha256=de1ce33da8c99ab1d97871726cba75151113f117146becbe45aa85cb3dabee3f
156+
prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
157+
public_suffix (4.0.7) sha256=8be161e2421f8d45b0098c042c06486789731ea93dc3a896d30554ee38b573b8
158+
rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142
159+
ruby-macho (2.5.1) sha256=9075e52e0f9270b552a90b24fcc6219ad149b0d15eae1bc364ecd0ac8984f5c9
160+
securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
161+
typhoeus (1.5.0) sha256=120b67ed1ef515e6c0e938176db880f15b0916f038e78ce2a66290f3f1de3e3b
162+
tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b
163+
xcodeproj (1.25.1) sha256=9a2310dccf6d717076e86f602b17c640046b6f1dfe64480044596f6f2f13dc84
164+
165+
RUBY VERSION
166+
ruby 3.4.8
167+
168+
BUNDLED WITH
169+
4.0.2

0 commit comments

Comments
 (0)