1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
2//
3// SPDX-License-Identifier: BSD-3-Clause
4
5package main
6
7import (
8 "bytes"
9 "embed"
10 "fmt"
11 "image"
12 "image/color"
13 "image/png"
14 "os"
15 "os/exec"
16 "strings"
17
18 "gioui.org/font/opentype"
19 "gioui.org/gpu/headless"
20 "gioui.org/io/router"
21 "gioui.org/layout"
22 "gioui.org/op"
23 "gioui.org/op/clip"
24 "gioui.org/op/paint"
25 "gioui.org/text"
26 "gioui.org/unit"
27 "gioui.org/widget/material"
28 "github.com/adrg/frontmatter"
29 flag "github.com/spf13/pflag"
30 "gopkg.in/yaml.v3"
31)
32
33var (
34 flagHelp *bool = flag.BoolP("help", "h", false, "Show the help message")
35 flagInput *string = flag.StringP("input", "i", "", "Path to input Markdown")
36 flagOutput *string = flag.StringP("output", "o", "", "Path to output PNG")
37 flagMetaSize *int = flag.IntP("metasize", "M", 40, "Size of font for meta information")
38 flagPostTitleSize *int = flag.IntP("posttitlesize", "P", 60, "Size of font for post title")
39 flagSiteTitleSize *int = flag.IntP("sitetitlesize", "S", 50, "Size of font for site title")
40 flagTitle *string = flag.StringP("posttitle", "t", "", "Title displayed in the generated image")
41 flagSubtitle *string = flag.StringP("subtitle", "s", "", "Subtitle displayed in the generated image")
42)
43
44//go:embed fonts
45var fontEmbed embed.FS
46
47func main() {
48 flag.Parse()
49
50 if *flagHelp {
51 help()
52 os.Exit(0)
53 }
54
55 manual := validateFlags()
56
57 var postTitle, postDate, postContent, postReadTime, dateEdited string
58
59 if !manual {
60 postTitle, postDate, postContent = getPostInfo(*flagInput)
61 postReadTime = getReadTime(postContent)
62 dateEdited = getEditDate(*flagInput)
63 }
64
65 siteTitle := getSiteTitle()
66
67 collection := fontCollection()
68
69 const scale = 1
70 sz := image.Point{X: 1200 * scale, Y: 630 * scale}
71
72 w, err := headless.NewWindow(sz.X, sz.Y)
73 if err != nil {
74 panic(err)
75 }
76 th := material.NewTheme(collection)
77 gtx := layout.Context{
78 Ops: new(op.Ops),
79 Queue: new(router.Router),
80 Constraints: layout.Exact(sz),
81 Metric: unit.Metric{
82 PxPerDp: scale,
83 PxPerSp: scale,
84 },
85 }
86 paint.Fill(gtx.Ops, color.NRGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff})
87 layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
88 gtx.Constraints.Max.X = int(float64(gtx.Constraints.Max.X) * .6)
89 gtx.Constraints.Min.X = gtx.Constraints.Max.X
90 gtx.Constraints.Min.Y = gtx.Constraints.Max.Y
91
92 return layout.Flex{Axis: layout.Vertical, Alignment: layout.Middle, Spacing: layout.SpaceAround}.Layout(gtx,
93
94 layout.Rigid(layout.Spacer{Height: 50}.Layout),
95
96 layout.Rigid(func(gtx layout.Context) layout.Dimensions {
97 gtx.Constraints.Max.X = int(float64(gtx.Constraints.Max.X) * .9)
98 gtx.Constraints.Min.X = gtx.Constraints.Max.X
99 var title material.LabelStyle
100 if manual {
101 title = material.Label(th, unit.Sp(float32(*flagPostTitleSize)), *flagTitle)
102 } else {
103 title = material.Label(th, unit.Sp(float32(*flagPostTitleSize)), postTitle)
104 }
105 title.Font = text.Font{Typeface: "Primary font", Variant: "", Style: text.Regular, Weight: text.Bold}
106 title.Alignment = text.Middle
107 return title.Layout(gtx)
108 }),
109
110 layout.Rigid(func(gtx layout.Context) layout.Dimensions {
111 if manual {
112 subtitle := material.Label(th, unit.Sp(float32(*flagMetaSize)), *flagSubtitle)
113 subtitle.Font = text.Font{Typeface: "Primary font", Variant: "", Style: text.Italic, Weight: text.Normal}
114 subtitle.Alignment = text.Middle
115 return subtitle.Layout(gtx)
116 } else {
117 gtx.Constraints.Max.X = int(float64(gtx.Constraints.Max.X) * .7)
118 gtx.Constraints.Min.X = gtx.Constraints.Max.X
119 return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
120 layout.Rigid(func(gtx layout.Context) layout.Dimensions {
121 return layout.Flex{Axis: layout.Horizontal, Spacing: layout.SpaceBetween}.Layout(gtx,
122 layout.Rigid(func(gtx layout.Context) layout.Dimensions {
123 rTime := material.Label(th, unit.Sp(float32(*flagMetaSize)), "Reading Time: ")
124 rTime.Font = text.Font{Typeface: "Primary font", Variant: "", Style: text.Regular, Weight: text.Bold}
125 rTime.Alignment = text.Start
126 return rTime.Layout(gtx)
127 }),
128 layout.Rigid(func(gtx layout.Context) layout.Dimensions {
129 rTime := material.Label(th, unit.Sp(float32(*flagMetaSize)), postReadTime)
130 rTime.Font = text.Font{Typeface: "Primary font", Variant: "", Style: text.Regular, Weight: text.Normal}
131 rTime.Alignment = text.End
132 return rTime.Layout(gtx)
133 }),
134 )
135 }),
136
137 layout.Rigid(func(gtx layout.Context) layout.Dimensions {
138 return layout.Flex{Axis: layout.Horizontal, Spacing: layout.SpaceBetween}.Layout(gtx,
139 layout.Rigid(func(gtx layout.Context) layout.Dimensions {
140 pDate := material.Label(th, unit.Sp(float32(*flagMetaSize)), "Published: ")
141 pDate.Font = text.Font{Typeface: "Primary font", Variant: "", Style: text.Regular, Weight: text.Bold}
142 pDate.Alignment = text.Start
143 return pDate.Layout(gtx)
144 }),
145 layout.Rigid(func(gtx layout.Context) layout.Dimensions {
146 pDate := material.Label(th, unit.Sp(float32(*flagMetaSize)), fmt.Sprint(postDate))
147 pDate.Font = text.Font{Typeface: "Primary font", Variant: "", Style: text.Regular, Weight: text.Normal}
148 pDate.Alignment = text.End
149 return pDate.Layout(gtx)
150 }),
151 )
152 }),
153
154 layout.Rigid(func(gtx layout.Context) layout.Dimensions {
155 return layout.Flex{Axis: layout.Horizontal, Spacing: layout.SpaceBetween}.Layout(gtx,
156 layout.Rigid(func(gtx layout.Context) layout.Dimensions {
157 eDate := material.Label(th, unit.Sp(float32(*flagMetaSize)), "Edited: ")
158 eDate.Font = text.Font{Typeface: "Primary font", Variant: "", Style: text.Regular, Weight: text.Bold}
159 eDate.Alignment = text.Start
160 return eDate.Layout(gtx)
161 }),
162 layout.Rigid(func(gtx layout.Context) layout.Dimensions {
163 eDate := material.Label(th, unit.Sp(float32(*flagMetaSize)), fmt.Sprint(dateEdited))
164 eDate.Font = text.Font{Typeface: "Primary font", Variant: "", Style: text.Regular, Weight: text.Normal}
165 eDate.Alignment = text.End
166 return eDate.Layout(gtx)
167 }),
168 )
169 }),
170 )
171 }
172 }),
173
174 layout.Rigid(func(gtx layout.Context) layout.Dimensions {
175 size := image.Pt(gtx.Constraints.Max.X, 4)
176 defer clip.Rect{Max: size}.Push(gtx.Ops).Pop()
177 paint.ColorOp{Color: color.NRGBA{A: 0xFF}}.Add(gtx.Ops)
178 paint.PaintOp{}.Add(gtx.Ops)
179 return layout.Dimensions{Size: size}
180 }),
181
182 layout.Rigid(func(gtx layout.Context) layout.Dimensions {
183 gtx.Constraints.Max.X = int(float64(gtx.Constraints.Max.X) * .9)
184 gtx.Constraints.Min.X = gtx.Constraints.Max.X
185 sTitle := material.Label(th, unit.Sp(float32(*flagSiteTitleSize)), siteTitle)
186 sTitle.Font = text.Font{Typeface: "Primary font", Variant: "", Style: text.Regular, Weight: text.Bold}
187 sTitle.Alignment = text.Middle
188 return sTitle.Layout(gtx)
189 }),
190 layout.Rigid(layout.Spacer{Height: 50}.Layout),
191 )
192 })
193 err = w.Frame(gtx.Ops)
194 if err != nil {
195 fmt.Println("Error: Could not render GUI")
196 fmt.Println(err)
197 os.Exit(1)
198 }
199 img := image.NewRGBA(image.Rectangle{Max: sz})
200 err = w.Screenshot(img)
201 if err != nil {
202 fmt.Println("Error: Could not transfer GUI to image")
203 fmt.Println(err)
204 os.Exit(1)
205 }
206 var buf bytes.Buffer
207 if err := png.Encode(&buf, img); err != nil {
208 fmt.Println("Error: Could not encode image to PNG")
209 fmt.Println(err)
210 os.Exit(1)
211 }
212 // Write the image to a file
213 err = os.WriteFile(*flagOutput, buf.Bytes(), 0o644)
214 if err != nil {
215 fmt.Println("Error: Could not write image to file")
216 fmt.Println(err)
217 os.Exit(1)
218 }
219
220 os.Exit(0)
221}
222
223func fontCollection() []text.FontFace {
224 regularFaceBytes, err := fontEmbed.ReadFile("fonts/regular.otf")
225 if err != nil {
226 fmt.Println("Error: Could not read regular font")
227 fmt.Println(err)
228 os.Exit(1)
229 }
230 regularFace, err := opentype.Parse(regularFaceBytes)
231 if err != nil {
232 fmt.Println("Error: Could not parse regular font")
233 fmt.Println(err)
234 os.Exit(1)
235 }
236
237 boldFaceBytes, err := fontEmbed.ReadFile("fonts/bold.otf")
238 if err != nil {
239 fmt.Println("Error: Could not read bold font")
240 fmt.Println(err)
241 os.Exit(1)
242 }
243 boldFace, err := opentype.Parse(boldFaceBytes)
244 if err != nil {
245 fmt.Println("Error: Could not parse bold font")
246 fmt.Println(err)
247 os.Exit(1)
248 }
249
250 italicFaceBytes, err := fontEmbed.ReadFile("fonts/italic.otf")
251 if err != nil {
252 fmt.Println("Error: Could not read italic font")
253 fmt.Println(err)
254 os.Exit(1)
255 }
256 italicFace, err := opentype.Parse(italicFaceBytes)
257 if err != nil {
258 fmt.Println("Error: Could not parse italic font")
259 fmt.Println(err)
260 os.Exit(1)
261 }
262
263 boldItalicFaceBytes, err := fontEmbed.ReadFile("fonts/bold-italic.otf")
264 if err != nil {
265 fmt.Println("Error: Could not read bold italic font")
266 fmt.Println(err)
267 os.Exit(1)
268 }
269 boldItalicFace, err := opentype.Parse(boldItalicFaceBytes)
270 if err != nil {
271 fmt.Println("Error: Could not parse bold italic font")
272 fmt.Println(err)
273 os.Exit(1)
274 }
275
276 return []text.FontFace{
277 {
278 Font: text.Font{
279 Typeface: "Primary font",
280 Variant: "",
281 Style: text.Regular,
282 Weight: text.Normal,
283 },
284 Face: regularFace,
285 },
286 {
287 Font: text.Font{
288 Typeface: "Primary font",
289 Variant: "",
290 Style: text.Regular,
291 Weight: text.Bold,
292 },
293 Face: boldFace,
294 },
295 {
296 Font: text.Font{
297 Typeface: "Primary font",
298 Variant: "",
299 Style: text.Italic,
300 Weight: text.Normal,
301 },
302 Face: italicFace,
303 },
304 {
305 Font: text.Font{
306 Typeface: "Primary font",
307 Variant: "",
308 Style: text.Italic,
309 Weight: text.Bold,
310 },
311 Face: boldItalicFace,
312 },
313 }
314}
315
316// Print help message
317func help() {
318 fmt.Println("\nUsage: p2c [options]")
319 fmt.Println("\nOptions:")
320 flag.PrintDefaults()
321 fmt.Print(`
322example: p2c -i input.md -o output.png
323
324p2c is meant for use with Hugo.
325
326It looks at...
327- The Markdown file's frontmatter fields for
328 - title
329 - date
330- The site's config.{toml/yaml/yml} fields for
331 - title (site title)
332- The file's last modification date according to
333 the git history, falling back to the filesystem
334 metadata if git history is not available
335
336`)
337}
338
339// Validate flags and return true if the user is manually specifying the title
340// and subtitle
341func validateFlags() bool {
342 ret := false
343 if *flagInput == "" && (*flagTitle == "" || *flagSubtitle == "") {
344 fmt.Println("Error: input file not specified and title or subtitle not specified. Please specify EITHER an input file or both a title AND subtitle.")
345 os.Exit(1)
346 } else if *flagInput != "" && (*flagTitle != "" || *flagSubtitle != "") {
347 fmt.Println("Warning: input file specified and title or subtitle specified. Ignoring title and subtitle flags and continuing with input file")
348 } else if *flagTitle != "" && *flagSubtitle != "" {
349 ret = true
350 } else if *flagTitle == "" && *flagSubtitle != "" {
351 fmt.Println("Error: subtitle specified but title not specified")
352 os.Exit(1)
353 } else if *flagTitle != "" && *flagSubtitle == "" {
354 fmt.Println("Error: title specified but subtitle not specified")
355 os.Exit(1)
356 }
357
358 if *flagOutput == "" {
359 fmt.Println("Error: No output file specified")
360 os.Exit(1)
361 }
362
363 return ret
364}
365
366// Get the post's title, subtitle, and date
367func getPostInfo(input string) (string, string, string) {
368 if _, err := os.Stat(input); os.IsNotExist(err) {
369 fmt.Println("Error: Input file does not exist")
370 fmt.Println(err)
371 os.Exit(1)
372 }
373
374 fileContents, err := os.ReadFile(input)
375 if err != nil {
376 fmt.Println("Error: Could not read input file")
377 fmt.Println(err)
378 os.Exit(1)
379 }
380
381 var fm struct {
382 Title string `yaml:"title"`
383 Date string `yaml:"date"`
384 }
385
386 content, err := frontmatter.Parse(strings.NewReader(string(fileContents)), &fm)
387 if err != nil {
388 fmt.Println("Error: Could not parse frontmatter")
389 fmt.Println(err)
390 os.Exit(1)
391 }
392
393 fm.Date = strings.Split(fm.Date, "T")[0]
394
395 return fm.Title, fm.Date, string(content)
396}
397
398// Get the read time of the post
399func getReadTime(content string) string {
400 wordCount := len(strings.Fields(content))
401 if wordCount/200 == 0 || wordCount/200 == 1 {
402 return "1 minute"
403 }
404 return fmt.Sprintf("%d minutes", wordCount/200)
405}
406
407// Get the site's title
408func getSiteTitle() string {
409 validConf := ""
410 confs := []string{"config.toml", "config.yaml", "config.yml"}
411 for _, conf := range confs {
412 if _, err := os.Stat(conf); os.IsNotExist(err) {
413 continue
414 }
415 validConf = conf
416 }
417 if validConf == "" {
418 fmt.Println("Error: No valid config file found, please run this in the root of a Hugo site")
419 os.Exit(1)
420 }
421
422 var t struct {
423 Title string `yaml:"title"`
424 }
425
426 f, err := os.Open(validConf)
427 if err != nil {
428 fmt.Println("Error: Could not open config file")
429 fmt.Println(err)
430 os.Exit(1)
431 }
432 defer f.Close()
433
434 decoder := yaml.NewDecoder(f)
435 err = decoder.Decode(&t)
436 if err != nil {
437 fmt.Println("Error: Could not parse config file")
438 fmt.Println(err)
439 os.Exit(1)
440 }
441
442 return t.Title
443}
444
445// Get the date the post was last edited using the git log and falling back to
446// the file's last modified date
447func getEditDate(input string) string {
448 _, err := os.Stat(".git")
449 if err != nil {
450 fileInfo, err := os.Stat(input)
451 if err != nil {
452 fmt.Println("Error: Could not get file info")
453 fmt.Println(err)
454 os.Exit(1)
455 }
456
457 return fileInfo.ModTime().Format("2006-01-02")
458 }
459 cmd := exec.Command("git", "log", "-1", "--format=%ad", "--date=short", input)
460 out, err := cmd.Output()
461 if err != nil {
462 fmt.Println("Error: Could not get git log")
463 fmt.Println(err)
464 os.Exit(1)
465 }
466
467 return strings.TrimSpace(string(out))
468}