From 61809d63e687d8809cddcdbec8ecbb8e093cfba6 Mon Sep 17 00:00:00 2001 From: Martin von Gagern Date: Fri, 11 Jul 2014 11:24:40 +0200 Subject: [PATCH 1/8] Include dot in extension for graphics_filename(). This makes the function more similar to tmp_filename(), so people may have less trouble remembering where to include the dot and where not to. This also enables the use of suffixes which don't start in a dot, like those starting in a dash which are used by Graphics3d.show(). The old behaviour is deprecated but kept, at least for extensions starting in neither a dot nor a dash. That compatibility code may be removed a year after this change here got released. --- src/sage/misc/latex.py | 2 +- src/sage/misc/temporary_file.py | 22 +++++++++++++++++----- src/sage/plot/animate.py | 6 +++--- 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/src/sage/misc/latex.py b/src/sage/misc/latex.py index 41a1d12613d..9fd9d46efd0 100644 --- a/src/sage/misc/latex.py +++ b/src/sage/misc/latex.py @@ -2212,7 +2212,7 @@ def view(objects, title='Sage', debug=False, sep='', tiny=False, print(MathJax().eval(objects, mode=mode, combine_all=combine_all)) else: base_dir = os.path.abspath("") - png_file = graphics_filename(ext='png') + png_file = graphics_filename() png_link = "cell://" + png_file png(objects, os.path.join(base_dir, png_file), debug=debug, engine=engine) diff --git a/src/sage/misc/temporary_file.py b/src/sage/misc/temporary_file.py index 2580311a1d8..65e9212bf6f 100644 --- a/src/sage/misc/temporary_file.py +++ b/src/sage/misc/temporary_file.py @@ -147,7 +147,7 @@ def tmp_filename(name="tmp_", ext=""): return name -def graphics_filename(ext='png'): +def graphics_filename(ext='.png'): """ When run from the Sage notebook, return the next available canonical filename for a plot/graphics file in the current working directory. @@ -155,7 +155,7 @@ def graphics_filename(ext='png'): INPUT: - - ``ext`` -- (default: ``"png"``) A file extension (without the dot) + - ``ext`` -- (default: ``".png"``) A file extension (including the dot) for the filename. OUTPUT: @@ -176,16 +176,28 @@ def graphics_filename(ext='png'): We check that it's a file inside ``SAGE_TMP`` and that the extension is correct:: - sage: fn = graphics_filename(ext="jpeg") + sage: fn = graphics_filename(ext=".jpeg") sage: fn.startswith(str(SAGE_TMP)) True sage: fn.endswith('.jpeg') True + + Historically, it was also possible to omit the dot. This has been + changed in :trac:`16640` but it will still work for now:: + + sage: fn = graphics_filename("jpeg") + doctest:...: DeprecationWarning: extension must now include the dot + See http://trac.sagemath.org/16640 for details. + sage: fn.endswith('.jpeg') + True """ - ext = '.' + ext - # Don't use this unsafe function except in the notebook, #15515 + if ext[0] not in '.-': + from sage.misc.superseded import deprecation + deprecation(16640, "extension must now include the dot") + ext = '.' + ext import sage.plot.plot if sage.plot.plot.EMBEDDED_MODE: + # Don't use this unsafe function except in the notebook, #15515 i = 0 while os.path.exists('sage%d%s'%(i,ext)): i += 1 diff --git a/src/sage/plot/animate.py b/src/sage/plot/animate.py index 0a0eb141766..52494af48b3 100644 --- a/src/sage/plot/animate.py +++ b/src/sage/plot/animate.py @@ -561,7 +561,7 @@ def gif(self, delay=20, savefile=None, iterations=0, show_path=False, raise OSError(msg) else: if not savefile: - savefile = graphics_filename(ext='gif') + savefile = graphics_filename(ext='.gif') if not savefile.endswith('.gif'): savefile += '.gif' savefile = os.path.abspath(savefile) @@ -632,7 +632,7 @@ def show(self, delay=20, iterations=0): See www.imagemagick.org and www.ffmpeg.org for more information. """ - filename = graphics_filename(ext='gif') + filename = graphics_filename(ext='.gif') self.gif(savefile=filename, delay=delay, iterations=iterations) if not (sage.doctest.DOCTEST_MODE or plot.EMBEDDED_MODE): os.system('%s %s 2>/dev/null 1>/dev/null &'%( @@ -743,7 +743,7 @@ def ffmpeg(self, savefile=None, show_path=False, output_format=None, else: if output_format[0] != '.': output_format = '.'+output_format - savefile = graphics_filename(ext=output_format[1:]) + savefile = graphics_filename(ext=output_format) else: if output_format is None: suffix = os.path.splitext(savefile)[1] From 84a2f30d7d494ece9772605a86b85d3b90742fab Mon Sep 17 00:00:00 2001 From: Martin von Gagern Date: Fri, 11 Jul 2014 13:25:15 +0200 Subject: [PATCH 2/8] Generate names for 3d graphics files independently. Instead of basing everything on a single call to graphics_filename, we now have one call for (almost) every file which is being generated. This ensures that the files with the corresponding extensions are indeed new. Up until now, creating multiple 3d results in a single cell would likely lead to clashes. --- src/sage/plot/plot3d/base.pyx | 64 +++++++++++++++++++---------------- 1 file changed, 35 insertions(+), 29 deletions(-) diff --git a/src/sage/plot/plot3d/base.pyx b/src/sage/plot/plot3d/base.pyx index 2b7b392701e..21a9750bfbb 100644 --- a/src/sage/plot/plot3d/base.pyx +++ b/src/sage/plot/plot3d/base.pyx @@ -45,7 +45,7 @@ from sage.modules.free_module_element import vector from sage.rings.real_double import RDF from sage.misc.functional import sqrt, atan, acos -from sage.misc.temporary_file import tmp_filename +from sage.misc.temporary_file import tmp_filename, graphics_filename from texture import Texture, is_Texture from transform cimport Transformation, point_c, face_c @@ -1141,58 +1141,64 @@ end_scene""" % (render_params.antialiasing, axes = opts['axes'] import sage.misc.misc - try: - filename = kwds.pop('filename') - except KeyError: - filename = tmp_filename() + basename = kwds.pop('filename', None) + def makename(ext): + if basename is not None: + return basename + ext + else: + # when testing this, modify graphics_filename so that it + # returns files which don't automatically share a base name, + # e.g.: "import random; i = random.randint(0, 10000)" + return graphics_filename(ext=ext) from sage.plot.plot import EMBEDDED_MODE from sage.doctest import DOCTEST_MODE - ext = None + filename = None # Tachyon resolution options if DOCTEST_MODE: opts = '-res 10 10' - filename = os.path.join(sage.misc.misc.SAGE_TMP, "tmp") + basename = None elif EMBEDDED_MODE: opts = '-res %s %s'%(figsize[0]*100, figsize[1]*100) - filename = sage.misc.temporary_file.graphics_filename()[:-4] + basename = None else: opts = '-res %s %s'%(figsize[0]*100, figsize[1]*100) if DOCTEST_MODE or viewer=='tachyon' or (viewer=='java3d' and EMBEDDED_MODE): T = self._prepare_for_tachyon(frame, axes, frame_aspect_ratio, aspect_ratio, zoom) - tachyon_rt(T.tachyon(), filename+".png", verbosity, True, opts) - ext = "png" + filename = makename(".png") + tachyon_rt(T.tachyon(), filename, verbosity, True, opts) import sage.misc.viewer viewer_app = sage.misc.viewer.png_viewer() if DOCTEST_MODE or viewer=='java3d': - f = open(filename+".obj", "w") - f.write("mtllib %s.mtl\n" % filename) + mtl = makename(".mtl") + filename = makename(".obj") + f = open(filename, "w") + f.write("mtllib %s\n" % mtl) f.write(self.obj()) f.close() - f = open(filename+".mtl", "w") + f = open(mtl, "w") f.write(self.mtl_str()) f.close() - ext = "obj" viewer_app = os.path.join(sage.misc.misc.SAGE_LOCAL, "bin/sage3d") if DOCTEST_MODE or viewer=='jmol': # Temporary hack: encode the desired applet size in the end of the filename: # (This will be removed once we have dynamic resizing of applets in the browser.) - base, ext = os.path.splitext(filename) - fg = figsize[0] - filename = '%s-size%s%s'%(base, fg*100, ext) + fg = figsize[0]*100 + sizedname = lambda ext: makename("-size{}{}".format(fg*100, ext)) if EMBEDDED_MODE: - ext = "jmol" # jmol doesn't seem to correctly parse the ?params part of a URL - archive_name = "%s-%s.%s.zip" % (filename, randint(0, 1 << 30), ext) + archive_name = sizedname( + "-{}.jmol.zip".format(randint(0, 1 << 30))) + filename = sizedname(".jmol") else: - ext = "spt" - archive_name = "%s.%s.zip" % (filename, ext) - with open(filename + '.' + ext, 'w') as f: + archive_name = sizedname(".spt.zip".format(fg)) + filename = sizedname(".spt") + with open(filename, 'w') as f: f.write('set defaultdirectory "{0}"\n'.format(archive_name)) f.write('script SCRIPT\n') @@ -1209,14 +1215,14 @@ end_scene""" % (render_params.antialiasing, # button. import sagenb path = "cells/%s/%s" %(sagenb.notebook.interact.SAGE_CELL_ID, archive_name) - with open(filename + '.' + ext, 'w') as f: + with open(filename, 'w') as f: f.write('set defaultdirectory "%s"\n' % path) f.write('script SCRIPT\n') # Filename for the static image png_path = '.jmol_images' sage.misc.misc.sage_makedirs(png_path) - png_name = os.path.join(png_path, filename + ".jmol.png") + png_name = os.path.join(png_path, filename + ".png") from sage.interfaces.jmoldata import JmolData jdata = JmolData() @@ -1231,15 +1237,15 @@ end_scene""" % (render_params.antialiasing, T = self._prepare_for_tachyon(frame, axes, frame_aspect_ratio, aspect_ratio, zoom) tachyon_rt(T.tachyon(), png_name, verbosity, True, opts) - if viewer == 'canvas3d': + if DOCTEST_MODE or (viewer == 'canvas3d' and EMBEDDED_MODE): T = self._prepare_for_tachyon(frame, axes, frame_aspect_ratio, aspect_ratio, zoom) data = flatten_list(T.json_repr(T.default_render_params())) - f = open(filename + '.canvas3d', 'w') + filename = makename('.canvas3d') + f = open(filename, 'w') f.write('[%s]' % ','.join(data)) f.close() - ext = 'canvas3d' - if ext is None: + if filename is None: raise ValueError("Unknown 3d plot type: %s" % viewer) if not DOCTEST_MODE and not EMBEDDED_MODE: @@ -1247,7 +1253,7 @@ end_scene""" % (render_params.antialiasing, pipes = "2>&1" else: pipes = "2>/dev/null 1>/dev/null &" - os.system('%s "%s.%s" %s' % (viewer_app, filename, ext, pipes)) + os.system('%s "%s" %s' % (viewer_app, filename, pipes)) def save_image(self, filename=None, *args, **kwds): r""" From 1a48c6bf21a4048c035799af58e7a8688a9e0245 Mon Sep 17 00:00:00 2001 From: Jeroen Demeyer Date: Sat, 11 Oct 2014 22:02:34 +0200 Subject: [PATCH 3/8] Use "with open() as f" syntax --- src/sage/plot/plot3d/base.pyx | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/src/sage/plot/plot3d/base.pyx b/src/sage/plot/plot3d/base.pyx index e140eafd11f..f4bd82a43f7 100644 --- a/src/sage/plot/plot3d/base.pyx +++ b/src/sage/plot/plot3d/base.pyx @@ -1185,13 +1185,11 @@ end_scene""" % (render_params.antialiasing, if DOCTEST_MODE or viewer=='java3d': mtl = makename(".mtl") filename = makename(".obj") - f = open(filename, "w") - f.write("mtllib %s\n" % mtl) - f.write(self.obj()) - f.close() - f = open(mtl, "w") - f.write(self.mtl_str()) - f.close() + with open(filename, "w") as f: + f.write("mtllib %s\n" % mtl) + f.write(self.obj()) + with open(mtl, "w") as f: + f.write(self.mtl_str()) viewer_app = os.path.join(sage.misc.misc.SAGE_LOCAL, "bin/sage3d") if DOCTEST_MODE or viewer=='jmol': @@ -1251,9 +1249,8 @@ end_scene""" % (render_params.antialiasing, T = self._prepare_for_tachyon(frame, axes, frame_aspect_ratio, aspect_ratio, zoom) data = flatten_list(T.json_repr(T.default_render_params())) filename = makename('.canvas3d') - f = open(filename, 'w') - f.write('[%s]' % ','.join(data)) - f.close() + with open(filename, 'w') as f: + f.write('[%s]' % ','.join(data)) if filename is None: raise ValueError("Unknown 3d plot type: %s" % viewer) From 49ce20b63e2dc39d611f453f76077620f4faccfb Mon Sep 17 00:00:00 2001 From: Jeroen Demeyer Date: Sat, 11 Oct 2014 22:26:00 +0200 Subject: [PATCH 4/8] Set fg to figsize[0] like before --- src/sage/plot/plot3d/base.pyx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sage/plot/plot3d/base.pyx b/src/sage/plot/plot3d/base.pyx index f4bd82a43f7..e77bbe4b9ee 100644 --- a/src/sage/plot/plot3d/base.pyx +++ b/src/sage/plot/plot3d/base.pyx @@ -1195,7 +1195,7 @@ end_scene""" % (render_params.antialiasing, if DOCTEST_MODE or viewer=='jmol': # Temporary hack: encode the desired applet size in the end of the filename: # (This will be removed once we have dynamic resizing of applets in the browser.) - fg = figsize[0]*100 + fg = figsize[0] sizedname = lambda ext: makename("-size{}{}".format(fg*100, ext)) if EMBEDDED_MODE: From 463eb63585d45e209938fcd8b8dd91d1a488d2e4 Mon Sep 17 00:00:00 2001 From: Jeroen Demeyer Date: Sat, 11 Oct 2014 22:56:33 +0200 Subject: [PATCH 5/8] Rendering canvas3d in all doctests is too slow --- src/sage/plot/plot3d/base.pyx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sage/plot/plot3d/base.pyx b/src/sage/plot/plot3d/base.pyx index e77bbe4b9ee..b69110c022e 100644 --- a/src/sage/plot/plot3d/base.pyx +++ b/src/sage/plot/plot3d/base.pyx @@ -1245,7 +1245,7 @@ end_scene""" % (render_params.antialiasing, T = self._prepare_for_tachyon(frame, axes, frame_aspect_ratio, aspect_ratio, zoom) tachyon_rt(T.tachyon(), png_name, verbosity, True, opts) - if DOCTEST_MODE or (viewer == 'canvas3d' and EMBEDDED_MODE): + if viewer == 'canvas3d': T = self._prepare_for_tachyon(frame, axes, frame_aspect_ratio, aspect_ratio, zoom) data = flatten_list(T.json_repr(T.default_render_params())) filename = makename('.canvas3d') From bd1479d38fe1adb42755ab92499ff8c1ba35592d Mon Sep 17 00:00:00 2001 From: Jeroen Demeyer Date: Fri, 31 Oct 2014 09:33:51 +0100 Subject: [PATCH 6/8] Make viewer="canvas3d" an error outside the Notebook --- src/sage/plot/plot3d/base.pyx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/sage/plot/plot3d/base.pyx b/src/sage/plot/plot3d/base.pyx index 0a692003819..6d513cfb469 100644 --- a/src/sage/plot/plot3d/base.pyx +++ b/src/sage/plot/plot3d/base.pyx @@ -1259,6 +1259,8 @@ end_scene""" % (render_params.antialiasing, tachyon_rt(T.tachyon(), png_name, verbosity, True, opts) if viewer == 'canvas3d': + if not EMBEDDED_MODE and not DOCTEST_MODE: + raise RuntimeError("canvas3d viewer is only available from the Notebook") T = self._prepare_for_tachyon(frame, axes, frame_aspect_ratio, aspect_ratio, zoom) data = flatten_list(T.json_repr(T.default_render_params())) filename = makename('.canvas3d') From 3852a0a23966400e02a14512e97e8ae46e45ec2a Mon Sep 17 00:00:00 2001 From: Jeroen Demeyer Date: Fri, 31 Oct 2014 18:18:35 +0100 Subject: [PATCH 7/8] Further improve 3d graphics file handling --- src/sage/plot/plot3d/base.pyx | 55 ++++++++++++++++------------- src/sage/structure/graphics_file.py | 4 +-- 2 files changed, 31 insertions(+), 28 deletions(-) diff --git a/src/sage/plot/plot3d/base.pyx b/src/sage/plot/plot3d/base.pyx index 6d513cfb469..27053f3b155 100644 --- a/src/sage/plot/plot3d/base.pyx +++ b/src/sage/plot/plot3d/base.pyx @@ -39,7 +39,8 @@ from random import randint import zipfile from cStringIO import StringIO -import sage.misc.misc +from sage.misc.misc import sage_makedirs +from sage.env import SAGE_LOCAL from sage.modules.free_module_element import vector @@ -109,7 +110,6 @@ cdef class Graphics3d(SageObject): Mime, graphics_from_save, GraphicsFile) if (mime_types is None) or (Mime.JMOL in mime_types): # default to jmol - from sage.misc.temporary_file import tmp_filename filename = tmp_filename( ext=os.path.extsep + Mime.extension(Mime.JMOL)) self.save(filename) @@ -1060,7 +1060,7 @@ end_scene""" % (render_params.antialiasing, return opts - def show(self, **kwds): + def show(self, *, filename=None, **kwds): """ INPUT: @@ -1076,8 +1076,8 @@ end_scene""" % (render_params.antialiasing, * 'canvas3d': Web-based 3D viewer powered by JavaScript and (notebook only) - - ``filename`` -- string (default: a temp file); file - to save the image to + - ``filename`` -- string (default: a temp file); filename + without extension to save the image file(s) to - ``verbosity`` -- display information about rendering the figure @@ -1150,8 +1150,20 @@ end_scene""" % (render_params.antialiasing, the plot rendered inline using HTML canvas:: sage: p.show(viewer='canvas3d') - """ + From the command line, you probably want to specify an explicit + filename:: + + sage: basedir = tmp_dir() + sage: basename = os.path.join(basedir, "xyz") + sage: p.show(filename=basename) + + In this doctest, we see many files because we test various + viewers:: + + sage: sorted(os.listdir(basedir)) + ['xyz-size500.spt', 'xyz-size500.spt.zip', 'xyz.mtl', 'xyz.obj', 'xyz.png'] + """ opts = self._process_viewing_options(kwds) viewer = opts['viewer'] @@ -1163,35 +1175,28 @@ end_scene""" % (render_params.antialiasing, frame = opts['frame'] axes = opts['axes'] - import sage.misc.misc - basename = kwds.pop('filename', None) + basename = filename # Filename without extension + filename = None # Filename with extension of the plot result + def makename(ext): if basename is not None: return basename + ext else: - # when testing this, modify graphics_filename so that it - # returns files which don't automatically share a base name, - # e.g.: "import random; i = random.randint(0, 10000)" return graphics_filename(ext=ext) from sage.plot.plot import EMBEDDED_MODE from sage.doctest import DOCTEST_MODE - filename = None # Tachyon resolution options if DOCTEST_MODE: - opts = '-res 10 10' - basename = None - elif EMBEDDED_MODE: - opts = '-res %s %s'%(figsize[0]*100, figsize[1]*100) - basename = None + tachyon_opts = '-res 10 10' else: - opts = '-res %s %s'%(figsize[0]*100, figsize[1]*100) + tachyon_opts = '-res %s %s'%(figsize[0]*100, figsize[1]*100) if DOCTEST_MODE or viewer=='tachyon' or (viewer=='java3d' and EMBEDDED_MODE): T = self._prepare_for_tachyon(frame, axes, frame_aspect_ratio, aspect_ratio, zoom) filename = makename(".png") - tachyon_rt(T.tachyon(), filename, verbosity, True, opts) + tachyon_rt(T.tachyon(), filename, verbosity, True, tachyon_opts) import sage.misc.viewer viewer_app = sage.misc.viewer.png_viewer() @@ -1203,7 +1208,7 @@ end_scene""" % (render_params.antialiasing, f.write(self.obj()) with open(mtl, "w") as f: f.write(self.mtl_str()) - viewer_app = os.path.join(sage.misc.misc.SAGE_LOCAL, "bin/sage3d") + viewer_app = os.path.join(SAGE_LOCAL, "bin", "sage3d") if DOCTEST_MODE or viewer=='jmol': # Temporary hack: encode the desired applet size in the end of the filename: @@ -1225,7 +1230,7 @@ end_scene""" % (render_params.antialiasing, T = self._prepare_for_jmol(frame, axes, frame_aspect_ratio, aspect_ratio, zoom) T.export_jmol(archive_name, force_reload=EMBEDDED_MODE, zoom=zoom*100, **kwds) - viewer_app = os.path.join(sage.misc.misc.SAGE_LOCAL, "bin", "jmol") + viewer_app = os.path.join(SAGE_LOCAL, "bin", "jmol") # If the server has a Java installation we can make better static images with Jmol # Test for Java then make image with Jmol or Tachyon if no JavaVM @@ -1242,7 +1247,7 @@ end_scene""" % (render_params.antialiasing, # Filename for the static image png_path = '.jmol_images' - sage.misc.misc.sage_makedirs(png_path) + sage_makedirs(png_path) png_name = os.path.join(png_path, filename + ".png") from sage.interfaces.jmoldata import JmolData @@ -1256,7 +1261,7 @@ end_scene""" % (render_params.antialiasing, else: # Render the image with tachyon T = self._prepare_for_tachyon(frame, axes, frame_aspect_ratio, aspect_ratio, zoom) - tachyon_rt(T.tachyon(), png_name, verbosity, True, opts) + tachyon_rt(T.tachyon(), png_name, verbosity, True, tachyon_opts) if viewer == 'canvas3d': if not EMBEDDED_MODE and not DOCTEST_MODE: @@ -1354,7 +1359,7 @@ end_scene""" % (render_params.antialiasing, out_filename = filename else: # Save to a temporary file, and then convert using PIL - out_filename = sage.misc.temporary_file.tmp_filename(ext=ext) + out_filename = tmp_filename(ext=ext) tachyon_rt(T.tachyon(), out_filename, opts['verbosity'], True, '-res %s %s' % (opts['figsize'][0]*100, opts['figsize'][1]*100)) if ext != '.png': @@ -2081,7 +2086,7 @@ class RenderParams(SageObject): sage: params.foo 'x' """ - self.output_file = sage.misc.misc.tmp_filename() + self.output_file = tmp_filename() self.obj_vertex_offset = 1 self.transform_list = [] self.transform = None diff --git a/src/sage/structure/graphics_file.py b/src/sage/structure/graphics_file.py index 5c0528ba85b..96e5ff4ecfd 100644 --- a/src/sage/structure/graphics_file.py +++ b/src/sage/structure/graphics_file.py @@ -203,9 +203,7 @@ def sagenb_embedding(self): with it. """ from sage.misc.temporary_file import graphics_filename - ext = Mime.extension(self.mime()) - if sage.doctest.DOCTEST_MODE: - return + ext = "." + Mime.extension(self.mime()) self.save_as(graphics_filename(ext=ext)) From 034fa58b0051be5db6942a76c06010e15a3d664b Mon Sep 17 00:00:00 2001 From: Jeroen Demeyer Date: Mon, 17 Nov 2014 10:14:44 +0100 Subject: [PATCH 8/8] Fix doctest to use existing file --- src/sage/repl/display/formatter.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/sage/repl/display/formatter.py b/src/sage/repl/display/formatter.py index 670d20829f8..90d01c11132 100644 --- a/src/sage/repl/display/formatter.py +++ b/src/sage/repl/display/formatter.py @@ -312,7 +312,8 @@ def __call__(self, obj): ....: def _graphics_(self, **kwds): ....: print('showing graphics') ....: from sage.structure.graphics_file import GraphicsFile - ....: return GraphicsFile('/nonexistent.png', 'image/png') + ....: from sage.misc.temporary_file import graphics_filename + ....: return GraphicsFile(graphics_filename('.png'), 'image/png') ....: def _repr_(self): ....: return 'Textual representation' sage: from sage.repl.display.formatter import SageNBTextFormatter