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