1+ <!DOCTYPE html>
2+ < html lang ="en ">
3+ < head >
4+ < meta charset ="UTF-8 ">
5+ < meta name ="viewport " content ="width=device-width, initial-scale=1.0 ">
6+ < title > Print Without Black Ink | Free PDF Color Converter</ title >
7+ < meta name ="description " content ="Convert your PDFs to use color ink instead of black. 100% free, runs locally in your browser for total privacy. ">
8+
9+ < script src ="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js "> </ script >
10+ < script src ="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js "> </ script >
11+
12+ < style >
13+ : root {
14+ --primary : # 004aad ;
15+ --primary-hover : # 003380 ;
16+ --bg : # f8f9fc ;
17+ --surface : # ffffff ;
18+ --text : # 333 ;
19+ --border : # e2e8f0 ;
20+ }
21+ body {
22+ font-family : system-ui, -apple-system, sans-serif;
23+ background-color : var (--bg );
24+ color : var (--text );
25+ margin : 0 ;
26+ padding : 2rem 1rem ;
27+ display : flex;
28+ justify-content : center;
29+ }
30+ .container {
31+ background : var (--surface );
32+ padding : 2rem ;
33+ border-radius : 12px ;
34+ box-shadow : 0 10px 25px rgba (0 , 0 , 0 , 0.05 );
35+ max-width : 900px ;
36+ width : 100% ;
37+ }
38+ h1 { color : var (--primary ); margin-top : 0 ; text-align : center; }
39+ .hero-text { text-align : center; color : # 555 ; margin-bottom : 2rem ; }
40+ .badge { background : # e0f2fe ; color : # 0284c7 ; padding : 4px 8px ; border-radius : 4px ; font-size : 0.8rem ; font-weight : bold; }
41+
42+ .controls-grid {
43+ display : grid;
44+ grid-template-columns : repeat (auto-fit, minmax (250px , 1fr ));
45+ gap : 1.5rem ;
46+ margin-bottom : 1.5rem ;
47+ background : var (--bg );
48+ padding : 1.5rem ;
49+ border-radius : 8px ;
50+ border : 1px solid var (--border );
51+ }
52+ label { font-weight : 600 ; display : block; margin-bottom : 0.5rem ; font-size : 0.9rem ; }
53+ select , input [type = "file" ] {
54+ width : 100% ;
55+ padding : 10px ;
56+ border : 1px solid var (--border );
57+ border-radius : 6px ;
58+ box-sizing : border-box;
59+ background : white;
60+ }
61+ .checkbox-group { display : flex; align-items : center; gap : 8px ; margin-top : 1rem ; }
62+ .checkbox-group input { width : auto; margin : 0 ; }
63+
64+ details { background : white; border : 1px solid var (--border ); border-radius : 6px ; padding : 10px ; margin-top : 1rem ; }
65+ summary { font-weight : 600 ; cursor : pointer; color : # 555 ; }
66+ .advanced-settings { margin-top : 1rem ; display : flex; flex-direction : column; gap : 1rem ; font-size : 0.9rem ; }
67+ .warning-text { color : # b91c1c ; font-size : 0.85rem ; margin-top : 4px ; }
68+ .help-text { color : # 666 ; font-size : 0.85rem ; margin-top : 4px ; }
69+
70+ .btn-group { display : flex; gap : 10px ; margin-top : 1.5rem ; }
71+ button {
72+ flex : 1 ;
73+ background-color : var (--primary );
74+ color : white;
75+ border : none;
76+ padding : 14px 24px ;
77+ border-radius : 8px ;
78+ font-size : 1rem ;
79+ font-weight : 600 ;
80+ cursor : pointer;
81+ transition : background 0.2s , opacity 0.2s ;
82+ }
83+ button : hover { background-color : var (--primary-hover ); }
84+ button : disabled { background-color : # cbd5e1 ; cursor : not-allowed; color : # 64748b ; }
85+ # downloadBtn { background-color : # 059669 ; }
86+ # downloadBtn : hover { background-color : # 047857 ; }
87+
88+ # status { text-align : center; margin-top : 1rem ; font-weight : 600 ; color : # 0284c7 ; }
89+
90+ /* Preview Section */
91+ .workspace { display : none; margin-top : 2rem ; border-top : 2px solid var (--border ); padding-top : 2rem ; }
92+ .pagination { display : flex; justify-content : center; align-items : center; gap : 1rem ; margin-bottom : 1rem ; }
93+ .pagination button { flex : none; padding : 8px 16px ; font-size : 0.9rem ; width : auto; }
94+
95+ .preview-panels { display : flex; gap : 1rem ; flex-wrap : wrap; }
96+ .panel { flex : 1 ; min-width : 300px ; display : flex; flex-direction : column; align-items : center; }
97+ .panel h3 { font-size : 1rem ; color : # 555 ; margin-bottom : 0.5rem ; }
98+ canvas {
99+ max-width : 100% ;
100+ height : auto;
101+ border : 1px solid # ccc ;
102+ box-shadow : 0 4px 6px rgba (0 , 0 , 0 , 0.1 );
103+ background : white;
104+ }
105+ </ style >
106+ </ head >
107+ < body >
108+
109+ < div class ="container ">
110+ < h1 > 🖨️ Print Without Black</ h1 >
111+ < p class ="hero-text ">
112+ Printer refusing to print because the black ink is empty? < br >
113+ Convert your PDF to a deep blue to force the printer to use your color cartridges.
114+ < span class ="badge "> 100% Free</ span > < span class ="badge "> Private (Local Browser)</ span >
115+ </ p >
116+
117+ < div class ="controls-grid ">
118+ < div >
119+ < label for ="pdfInput "> 1. Select your PDF Document</ label >
120+ < input type ="file " id ="pdfInput " accept ="application/pdf ">
121+
122+ < div class ="checkbox-group ">
123+ < input type ="checkbox " id ="forceGrayscale " checked >
124+ < label for ="forceGrayscale " style ="margin:0; font-weight: normal; ">
125+ Force Monochrome (Convert entire document to target color)
126+ </ label >
127+ </ div >
128+ < p class ="help-text " style ="margin-top:4px; "> Uncheck this if your PDF has colors you want to keep, and you *only* want to change the black text.</ p >
129+ </ div >
130+
131+ < div >
132+ < label for ="colorSelect "> 2. Choose Replacement Color</ label >
133+ < select id ="colorSelect ">
134+ < option value ="10,10,100 "> Deep Indigo (Best overall, uses Cyan + Magenta)</ option >
135+ < option value ="0,100,0 "> Forest Green (Use if Magenta is empty)</ option >
136+ < option value ="150,0,0 "> Maroon (Use if Cyan is empty)</ option >
137+ < option value ="100,0,150 "> Deep Violet (Use if Yellow is empty)</ option >
138+ </ select >
139+
140+ < details >
141+ < summary > ⚙️ Advanced Quality Settings</ summary >
142+ < div class ="advanced-settings ">
143+ < div >
144+ < label for ="pdfScale "> Render Scale: < span id ="scaleVal "> 2.0</ span > x</ label >
145+ < input type ="range " id ="pdfScale " value ="2.0 " min ="1.0 " max ="4.0 " step ="0.5 " style ="width:100%; ">
146+ < p class ="help-text "> Higher scale = sharper text but much larger file sizes.</ p >
147+ < p class ="warning-text "> Warning: Setting this above 3.0 may cause the browser to freeze on long documents.</ p >
148+ </ div >
149+ < div >
150+ < label for ="pdfQuality "> JPEG Compression: < span id ="qualityVal "> 90</ span > %</ label >
151+ < input type ="range " id ="pdfQuality " value ="0.90 " min ="0.5 " max ="1.0 " step ="0.05 " style ="width:100%; ">
152+ < p class ="help-text "> Lower quality reduces file size but may introduce visual artifacts around text.</ p >
153+ </ div >
154+ </ div >
155+ </ details >
156+ </ div >
157+ </ div >
158+
159+ < div class ="btn-group ">
160+ < button id ="previewBtn "> Load & Preview</ button >
161+ < button id ="downloadBtn " disabled > Download Ready PDF</ button >
162+ </ div >
163+
164+ < div id ="status "> </ div >
165+
166+ < div class ="workspace " id ="workspace ">
167+ < div class ="pagination ">
168+ < button id ="prevPageBtn " disabled > ◀ Previous</ button >
169+ < span id ="pageIndicator " style ="font-weight: bold; "> Page 1 of ?</ span >
170+ < button id ="nextPageBtn "> Next ▶</ button >
171+ </ div >
172+
173+ < div class ="preview-panels ">
174+ < div class ="panel ">
175+ < h3 > Original</ h3 >
176+ < canvas id ="originalCanvas "> </ canvas >
177+ </ div >
178+ < div class ="panel ">
179+ < h3 > Converted</ h3 >
180+ < canvas id ="previewCanvas "> </ canvas >
181+ </ div >
182+ </ div >
183+ </ div >
184+ </ div >
185+
186+ < script >
187+ // Initialize PDF.js
188+ pdfjsLib . GlobalWorkerOptions . workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js' ;
189+
190+ let loadedPdf = null ;
191+ let originalFileName = "document" ;
192+ let currentPage = 1 ;
193+
194+ // UI Listeners for Advanced Settings
195+ document . getElementById ( 'pdfScale' ) . addEventListener ( 'input' , ( e ) => {
196+ document . getElementById ( 'scaleVal' ) . innerText = parseFloat ( e . target . value ) . toFixed ( 1 ) ;
197+ } ) ;
198+ document . getElementById ( 'pdfQuality' ) . addEventListener ( 'input' , ( e ) => {
199+ document . getElementById ( 'qualityVal' ) . innerText = Math . round ( parseFloat ( e . target . value ) * 100 ) ;
200+ } ) ;
201+
202+ // Color Tinting Logic
203+ function applyColorTint ( ctx , width , height , targetRGB , forceMonochrome ) {
204+ const imgData = ctx . getImageData ( 0 , 0 , width , height ) ;
205+ const data = imgData . data ;
206+ const [ targetR , targetG , targetB ] = targetRGB . split ( ',' ) . map ( Number ) ;
207+
208+ for ( let i = 0 ; i < data . length ; i += 4 ) {
209+ let r = data [ i ] , g = data [ i + 1 ] , b = data [ i + 2 ] ;
210+ let gray = ( r * 0.299 + g * 0.587 + b * 0.114 ) ;
211+
212+ if ( forceMonochrome ) {
213+ // Map all grays to the target color gradient
214+ data [ i ] = targetR + ( 255 - targetR ) * ( gray / 255 ) ;
215+ data [ i + 1 ] = targetG + ( 255 - targetG ) * ( gray / 255 ) ;
216+ data [ i + 2 ] = targetB + ( 255 - targetB ) * ( gray / 255 ) ;
217+ } else {
218+ // Smart mode: Only change dark/grayscale pixels. Preserve vibrant colors.
219+ // Calculate saturation (how different the max and min RGB values are)
220+ let max = Math . max ( r , g , b ) ;
221+ let min = Math . min ( r , g , b ) ;
222+ let saturation = max === 0 ? 0 : ( max - min ) / max ;
223+
224+ // If it's desaturated (grayish) AND darkish, tint it
225+ if ( saturation < 0.25 && gray < 200 ) {
226+ data [ i ] = targetR + ( 255 - targetR ) * ( gray / 255 ) ;
227+ data [ i + 1 ] = targetG + ( 255 - targetG ) * ( gray / 255 ) ;
228+ data [ i + 2 ] = targetB + ( 255 - targetB ) * ( gray / 255 ) ;
229+ }
230+ }
231+ }
232+ ctx . putImageData ( imgData , 0 , 0 ) ;
233+ }
234+
235+ // Render a specific page to the UI
236+ async function renderPageToUI ( pageNum ) {
237+ if ( ! loadedPdf ) return ;
238+
239+ const statusDiv = document . getElementById ( 'status' ) ;
240+ const targetColor = document . getElementById ( 'colorSelect' ) . value ;
241+ const forceMonochrome = document . getElementById ( 'forceGrayscale' ) . checked ;
242+
243+ try {
244+ const page = await loadedPdf . getPage ( pageNum ) ;
245+ const viewport = page . getViewport ( { scale : 1.5 } ) ; // Fixed scale for screen preview
246+
247+ // Setup Original Canvas
248+ const origCanvas = document . getElementById ( 'originalCanvas' ) ;
249+ const origCtx = origCanvas . getContext ( '2d' ) ;
250+ origCanvas . width = viewport . width ;
251+ origCanvas . height = viewport . height ;
252+ await page . render ( { canvasContext : origCtx , viewport : viewport } ) . promise ;
253+
254+ // Setup Converted Canvas
255+ const prevCanvas = document . getElementById ( 'previewCanvas' ) ;
256+ const prevCtx = prevCanvas . getContext ( '2d' , { willReadFrequently : true } ) ;
257+ prevCanvas . width = viewport . width ;
258+ prevCanvas . height = viewport . height ;
259+
260+ // Draw original onto preview canvas, then apply tint
261+ prevCtx . drawImage ( origCanvas , 0 , 0 ) ;
262+ applyColorTint ( prevCtx , prevCanvas . width , prevCanvas . height , targetColor , forceMonochrome ) ;
263+
264+ // Update Pagination UI
265+ document . getElementById ( 'pageIndicator' ) . innerText = `Page ${ pageNum } of ${ loadedPdf . numPages } ` ;
266+ document . getElementById ( 'prevPageBtn' ) . disabled = pageNum <= 1 ;
267+ document . getElementById ( 'nextPageBtn' ) . disabled = pageNum >= loadedPdf . numPages ;
268+
269+ } catch ( err ) {
270+ console . error ( err ) ;
271+ statusDiv . innerText = "Error rendering page." ;
272+ }
273+ }
274+
275+ // Load PDF and initialize preview
276+ document . getElementById ( 'previewBtn' ) . addEventListener ( 'click' , async ( ) => {
277+ const fileInput = document . getElementById ( 'pdfInput' ) ;
278+ const statusDiv = document . getElementById ( 'status' ) ;
279+
280+ if ( ! fileInput . files . length ) {
281+ statusDiv . innerText = "Please select a PDF file first." ;
282+ return ;
283+ }
284+
285+ const file = fileInput . files [ 0 ] ;
286+ originalFileName = file . name . replace ( '.pdf' , '' ) ;
287+ statusDiv . innerText = "Loading PDF..." ;
288+
289+ const fileReader = new FileReader ( ) ;
290+ fileReader . onload = async function ( ) {
291+ try {
292+ const typedarray = new Uint8Array ( this . result ) ;
293+ loadedPdf = await pdfjsLib . getDocument ( { data : typedarray } ) . promise ;
294+ currentPage = 1 ;
295+
296+ await renderPageToUI ( currentPage ) ;
297+
298+ document . getElementById ( 'workspace' ) . style . display = 'block' ;
299+ document . getElementById ( 'downloadBtn' ) . disabled = false ;
300+ statusDiv . innerText = "Preview generated! Adjust settings or download." ;
301+
302+ } catch ( error ) {
303+ console . error ( error ) ;
304+ statusDiv . innerText = "Error loading PDF." ;
305+ }
306+ } ;
307+ fileReader . readAsArrayBuffer ( file ) ;
308+ } ) ;
309+
310+ // Pagination Controls
311+ document . getElementById ( 'prevPageBtn' ) . addEventListener ( 'click' , ( ) => {
312+ if ( currentPage > 1 ) {
313+ currentPage -- ;
314+ renderPageToUI ( currentPage ) ;
315+ }
316+ } ) ;
317+
318+ document . getElementById ( 'nextPageBtn' ) . addEventListener ( 'click' , ( ) => {
319+ if ( currentPage < loadedPdf . numPages ) {
320+ currentPage ++ ;
321+ renderPageToUI ( currentPage ) ;
322+ }
323+ } ) ;
324+
325+ // Download Full PDF
326+ document . getElementById ( 'downloadBtn' ) . addEventListener ( 'click' , async ( ) => {
327+ if ( ! loadedPdf ) return ;
328+
329+ const statusDiv = document . getElementById ( 'status' ) ;
330+ const targetColor = document . getElementById ( 'colorSelect' ) . value ;
331+ const forceMonochrome = document . getElementById ( 'forceGrayscale' ) . checked ;
332+ const scale = parseFloat ( document . getElementById ( 'pdfScale' ) . value ) ;
333+ const quality = parseFloat ( document . getElementById ( 'pdfQuality' ) . value ) ;
334+
335+ const { jsPDF } = window . jspdf ;
336+
337+ document . getElementById ( 'downloadBtn' ) . disabled = true ;
338+ document . getElementById ( 'previewBtn' ) . disabled = true ;
339+
340+ try {
341+ let newPdf = null ;
342+
343+ for ( let pageNum = 1 ; pageNum <= loadedPdf . numPages ; pageNum ++ ) {
344+ statusDiv . innerText = `Processing page ${ pageNum } of ${ loadedPdf . numPages } ... (High Quality)` ;
345+
346+ const page = await loadedPdf . getPage ( pageNum ) ;
347+ const viewport = page . getViewport ( { scale : scale } ) ;
348+
349+ const canvas = document . createElement ( 'canvas' ) ;
350+ const ctx = canvas . getContext ( '2d' , { willReadFrequently : true } ) ;
351+ canvas . width = viewport . width ;
352+ canvas . height = viewport . height ;
353+
354+ await page . render ( { canvasContext : ctx , viewport : viewport } ) . promise ;
355+
356+ applyColorTint ( ctx , canvas . width , canvas . height , targetColor , forceMonochrome ) ;
357+
358+ const pdfWidth = viewport . width / scale ;
359+ const pdfHeight = viewport . height / scale ;
360+
361+ if ( pageNum === 1 ) {
362+ newPdf = new jsPDF ( {
363+ orientation : pdfWidth > pdfHeight ? 'landscape' : 'portrait' ,
364+ unit : 'pt' ,
365+ format : [ pdfWidth , pdfHeight ]
366+ } ) ;
367+ } else {
368+ newPdf . addPage ( [ pdfWidth , pdfHeight ] , pdfWidth > pdfHeight ? 'landscape' : 'portrait' ) ;
369+ }
370+
371+ const imgDataUrl = canvas . toDataURL ( 'image/jpeg' , quality ) ;
372+ newPdf . addImage ( imgDataUrl , 'JPEG' , 0 , 0 , pdfWidth , pdfHeight ) ;
373+ }
374+
375+ statusDiv . innerText = "Done! Check your downloads folder." ;
376+
377+ const colorSelect = document . getElementById ( 'colorSelect' ) ;
378+ const colorName = colorSelect . options [ colorSelect . selectedIndex ] . text . split ( ' ' ) [ 1 ] ;
379+ newPdf . save ( `${ originalFileName } _${ colorName } _Printable.pdf` ) ;
380+
381+ } catch ( error ) {
382+ console . error ( error ) ;
383+ statusDiv . innerText = "An error occurred during download." ;
384+ } finally {
385+ document . getElementById ( 'downloadBtn' ) . disabled = false ;
386+ document . getElementById ( 'previewBtn' ) . disabled = false ;
387+ }
388+ } ) ;
389+ </ script >
390+
391+ </ body >
392+ </ html >
0 commit comments