main.go

  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}